From e39ecfdf79c7edbb8dae274623a6b011f5bdf9db Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:57:29 +0700 Subject: [PATCH] feat(dev): add Sort Text Lines tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder lines asc/desc/reverse with case-insensitive, natural (numeric) order, sort-by-key (before = or :), dedupe, trim, drop-blanks and trim-characters — client-side, handy for lining up env vars / k8s secrets before a line-by-line compare. Pure lib (11 tests) + island + EN/ID SEO + happy-path E2E. --- e2e/tools/sort-lines.spec.ts | 37 ++++++++ src/islands/dev/SortLines.tsx | 122 +++++++++++++++++++++++++++ src/registry/tool-seo.ts | 34 ++++++++ src/registry/tools.ts | 13 ++- src/tools/dev/sort-lines.lib.test.ts | 42 +++++++++ src/tools/dev/sort-lines.lib.ts | 60 +++++++++++++ 6 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 e2e/tools/sort-lines.spec.ts create mode 100644 src/islands/dev/SortLines.tsx create mode 100644 src/tools/dev/sort-lines.lib.test.ts create mode 100644 src/tools/dev/sort-lines.lib.ts diff --git a/e2e/tools/sort-lines.spec.ts b/e2e/tools/sort-lines.spec.ts new file mode 100644 index 0000000..ece3ad1 --- /dev/null +++ b/e2e/tools/sort-lines.spec.ts @@ -0,0 +1,37 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Tool islands lazy-load via ToolHost; on a cold dev server they hydrate a few +// seconds after navigation, and a fill() before that is discarded when React +// mounts with empty initial state. Re-fill until the reactive output confirms +// the island is live. +async function typeWhenReady(page: Page, value: string, expected: string) { + // Wait for the lazy island chunk to load + hydrate before interacting, else a + // fill() is discarded when React mounts with empty initial state. + await page.waitForLoadState('networkidle').catch(() => {}); + const input = page.getByPlaceholder(/Paste lines/); + const output = page.locator('textarea[readonly]'); + await input.waitFor({ state: 'visible' }); + await expect(async () => { + await input.fill(value); + await expect(output).toHaveValue(expected, { timeout: 2000 }); + }).toPass({ timeout: 30_000 }); +} + +test('sorts lines and reacts to the order control', async ({ page }) => { + await page.goto('/tools/sort-lines'); + await typeWhenReady(page, 'banana\napple\ncherry', 'apple\nbanana\ncherry'); + + await page.locator('select:has(option[value="reverse"])').selectOption('desc'); + await expect(page.locator('textarea[readonly]')).toHaveValue('cherry\nbanana\napple'); +}); + +test('sorts env-style lines by key with the option toggles', async ({ page }) => { + await page.goto('/tools/sort-lines'); + // Default ascending already orders these by first letter (a < d < z). + await typeWhenReady(page, 'ZONE=us\napi_key=1\nDB_HOST=x', 'api_key=1\nDB_HOST=x\nZONE=us'); + + // Toggling sort-by-key + ignore-case keeps a valid, stable ordering. + await page.getByLabel('Sort by key (before = or :)').check(); + await page.getByLabel('Ignore case').check(); + await expect(page.locator('textarea[readonly]')).toHaveValue('api_key=1\nDB_HOST=x\nZONE=us'); +}); diff --git a/src/islands/dev/SortLines.tsx b/src/islands/dev/SortLines.tsx new file mode 100644 index 0000000..b4b246b --- /dev/null +++ b/src/islands/dev/SortLines.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState } from 'react'; +import { TextArea } from '@/components/ui/TextArea'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { DownloadTextButton } from '@/components/ui/DownloadTextButton'; +import { sortTextLines, type SortLinesOptions } from '@/tools/dev/sort-lines.lib'; +import type { Lang } from '@/i18n/config'; + +type Dir = 'asc' | 'desc' | 'reverse'; + +const TR: Record string; +}> = { + en: { + intro: 'Reorder lines of text — ascending, descending or reversed — with options for case, natural (numeric) order, and sorting by the key before = or : (handy for env vars and k8s/Vault secrets). Everything runs in your browser; nothing is uploaded.', + input: 'Lines', output: 'Sorted', placeholder: 'Paste lines to sort…\nAPI_KEY=1\nDB_HOST=localhost', + direction: 'Order', asc: 'A → Z', desc: 'Z → A', reverse: 'Reverse (no sort)', + options: 'Options', + caseInsensitive: 'Ignore case', natural: 'Natural order (2 before 10)', + byKey: 'Sort by key (before = or :)', dedupe: 'Remove duplicate lines', + trimEach: 'Trim each line', dropBlanks: 'Remove blank lines', + trimChars: 'Trim characters', trimCharsPh: 'e.g. "\',', + count: (n) => `${n} line${n === 1 ? '' : 's'}`, + }, + id: { + intro: 'Urutkan baris teks — menaik, menurun, atau dibalik — dengan opsi case, urutan natural (angka), dan urutkan berdasarkan key sebelum = atau : (berguna untuk env var dan secret k8s/Vault). Semua berjalan di browser Anda; tidak ada yang diunggah.', + input: 'Baris', output: 'Terurut', placeholder: 'Tempel baris untuk diurutkan…\nAPI_KEY=1\nDB_HOST=localhost', + direction: 'Urutan', asc: 'A → Z', desc: 'Z → A', reverse: 'Balik (tanpa urut)', + options: 'Opsi', + caseInsensitive: 'Abaikan huruf besar/kecil', natural: 'Urutan natural (2 sebelum 10)', + byKey: 'Urutkan berdasarkan key (sebelum = atau :)', dedupe: 'Hapus baris duplikat', + trimEach: 'Rapikan tiap baris', dropBlanks: 'Hapus baris kosong', + trimChars: 'Pangkas karakter', trimCharsPh: 'mis. "\',', + count: (n) => `${n} baris`, + }, +}; + +export default function SortLines({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [text, setText] = useState(''); + const [direction, setDirection] = useState('asc'); + const [flags, setFlags] = useState>({}); + const [trimChars, setTrimChars] = useState(''); + + const output = useMemo( + () => sortTextLines(text, { ...flags, direction, trimChars }), + [text, flags, direction, trimChars], + ); + const outCount = output === '' ? 0 : output.split('\n').length; + + const toggle = (key: keyof typeof flags) => setFlags(p => ({ ...p, [key]: !p[key] })); + + const CHECKS: { key: keyof typeof flags; label: string }[] = [ + { key: 'caseInsensitive', label: t.caseInsensitive }, + { key: 'natural', label: t.natural }, + { key: 'byKey', label: t.byKey }, + { key: 'dedupe', label: t.dedupe }, + { key: 'trimEach', label: t.trimEach }, + { key: 'dropBlanks', label: t.dropBlanks }, + ]; + + return ( +
+

{t.intro}

+ +
+ + +
+ +
+ {t.options} +
+ {CHECKS.map(c => ( + + ))} +
+
+ +
+
+ {t.input} +