Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/superpowers/plans/2026-08-14-text-toolkit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Basic Text Toolkit — Plan

**Date:** 2026-08-14 · Category: Dev · Three focused tools sharing one lib.

Rationale: separate keyword-targeted pages ("word counter" alone is millions of searches/mo) beat one combined page that can only rank for one term.

## Shared lib `src/tools/dev/text.lib.ts` (pure, unit-tested)
- `countText(text): TextStats` — characters, charactersNoSpaces, words, sentences, paragraphs, lines, readingMinutes (≈200 wpm).
- Case fns + `CASES` list: upper, lower, Title, Sentence, camel, Pascal, snake, kebab, CONSTANT (tokenizer respects camelCase + separators).
- Cleanup fns + `CLEANUP_OPS` + `cleanup(text, keys)`: trimLines, collapseSpaces, removeBlankLines, removeLineBreaks, stripHtml, removeAccents (NFD + strip ̀–ͯ), dedupeLines, sortLines.

## Tools (islands, thin)
- `word-counter` (icon Baseline) → `WordCounter.tsx`: live stats grid + textarea.
- `case-converter` (icon CaseSensitive) → `CaseConverter.tsx`: input → case buttons → output + copy.
- `text-cleanup` (icon Brush) → `TextCleanup.tsx`: op checkboxes → live cleaned output + copy/download.

## Registry + SEO
3 ToolDefs in `tools.ts`; EN + ID SEO for each in `tool-seo.ts` (title/description/intro/howTo/faqs). Bahasa "tool" loanword.

## DoD
lib unit-tested (20 cases) · EN+ID SEO · vitest+lint+build green · 6 new routes built · develop→main→live verified.
56 changes: 56 additions & 0 deletions src/islands/dev/CaseConverter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { Button } from '@/components/ui/Button';
import { CopyButton } from '@/components/ui/CopyButton';
import { CASES } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, { intro: string; input: string; output: string; placeholder: string }> = {
en: {
intro: 'Convert text between UPPERCASE, lowercase, Title Case, Sentence case, camelCase, snake_case, kebab-case and more. Runs in your browser.',
input: 'Text',
output: 'Result',
placeholder: 'Type or paste your text…',
},
id: {
intro: 'Ubah teks antara UPPERCASE, lowercase, Title Case, Sentence case, camelCase, snake_case, kebab-case, dan lainnya. Berjalan di browser Anda.',
input: 'Teks',
output: 'Hasil',
placeholder: 'Ketik atau tempel teks Anda…',
},
};

export default function CaseConverter({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [output, setOutput] = useState('');

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="space-y-1">
<span className="block text-sm font-semibold">{t.input}</span>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={6} placeholder={t.placeholder} monospace={false} />
</div>

<div className="flex flex-wrap gap-2">
{CASES.map(c => (
<Button key={c.key} variant="secondary" onClick={() => setOutput(c.fn(text))} disabled={!text}>
{c.label}
</Button>
))}
</div>

{output && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.output}</span>
<CopyButton value={output} />
</div>
<TextArea value={output} readOnly rows={6} monospace={false} />
</div>
)}
</div>
);
}
74 changes: 74 additions & 0 deletions src/islands/dev/TextCleanup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { CopyButton } from '@/components/ui/CopyButton';
import { DownloadTextButton } from '@/components/ui/DownloadTextButton';
import { CLEANUP_OPS, cleanup } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const OP_LABELS: Record<Lang, Record<string, string>> = {
en: Object.fromEntries(CLEANUP_OPS.map(o => [o.key, o.label])),
id: {
trimLines: 'Rapikan tiap baris', collapseSpaces: 'Gabungkan spasi berulang',
removeBlankLines: 'Hapus baris kosong', removeLineBreaks: 'Hapus jeda baris (gabung)',
stripHtml: 'Hapus tag HTML', removeAccents: 'Hapus aksen/diakritik',
dedupeLines: 'Hapus baris duplikat', sortLines: 'Urutkan baris A→Z',
},
};

const TR: Record<Lang, { intro: string; input: string; output: string; ops: string; placeholder: string }> = {
en: {
intro: 'Clean up messy text — trim whitespace, collapse spaces, remove blank lines or line breaks, strip HTML, remove accents, dedupe and sort lines. Runs in your browser.',
input: 'Text', output: 'Cleaned', ops: 'Operations', placeholder: 'Paste messy text…',
},
id: {
intro: 'Bersihkan teks berantakan — rapikan spasi, gabungkan spasi, hapus baris kosong atau jeda baris, hapus HTML, hapus aksen, hapus duplikat, dan urutkan baris. Berjalan di browser Anda.',
input: 'Teks', output: 'Hasil bersih', ops: 'Operasi', placeholder: 'Tempel teks berantakan…',
},
};

export default function TextCleanup({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const [ops, setOps] = useState<string[]>(['trimLines', 'collapseSpaces', 'removeBlankLines']);

const output = useMemo(() => cleanup(text, ops), [text, ops]);
const labels = OP_LABELS[lang] ?? OP_LABELS.en;

const toggle = (key: string) =>
setOps(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="space-y-1">
<span className="block text-sm font-semibold">{t.ops}</span>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{CLEANUP_OPS.map(o => (
<label key={o.key} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={ops.includes(o.key)} onChange={() => toggle(o.key)} className="h-4 w-4 accent-accent" />
{labels[o.key]}
</label>
))}
</div>
</div>

<div className="grid gap-3 lg:grid-cols-2">
<div className="space-y-1">
<span className="block text-sm font-semibold">{t.input}</span>
<TextArea value={text} onChange={e => setText(e.target.value)} rows={12} placeholder={t.placeholder} monospace={false} />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{t.output}</span>
<div className="flex gap-2">
<DownloadTextButton text={output} filename="cleaned.txt" />
<CopyButton value={output} />
</div>
</div>
<TextArea value={output} readOnly rows={12} monospace={false} />
</div>
</div>
</div>
);
}
67 changes: 67 additions & 0 deletions src/islands/dev/WordCounter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { countText } from '@/tools/dev/text.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string;
placeholder: string;
words: string;
characters: string;
charactersNoSpaces: string;
sentences: string;
paragraphs: string;
lines: string;
reading: string;
min: string;
}> = {
en: {
intro: 'Count words, characters, sentences, paragraphs and reading time as you type. Everything runs in your browser — nothing is uploaded.',
placeholder: 'Type or paste your text…',
words: 'Words', characters: 'Characters', charactersNoSpaces: 'Characters (no spaces)',
sentences: 'Sentences', paragraphs: 'Paragraphs', lines: 'Lines', reading: 'Reading time', min: 'min',
},
id: {
intro: 'Hitung kata, karakter, kalimat, paragraf, dan waktu baca saat Anda mengetik. Semuanya berjalan di browser Anda — tidak ada yang diunggah.',
placeholder: 'Ketik atau tempel teks Anda…',
words: 'Kata', characters: 'Karakter', charactersNoSpaces: 'Karakter (tanpa spasi)',
sentences: 'Kalimat', paragraphs: 'Paragraf', lines: 'Baris', reading: 'Waktu baca', min: 'mnt',
},
};

export default function WordCounter({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [text, setText] = useState('');
const stats = useMemo(() => countText(text), [text]);

const readMin = stats.readingMinutes < 1 && stats.words > 0
? '< 1'
: String(Math.round(stats.readingMinutes));

const cards = [
{ label: t.words, value: stats.words.toLocaleString() },
{ label: t.characters, value: stats.characters.toLocaleString() },
{ label: t.charactersNoSpaces, value: stats.charactersNoSpaces.toLocaleString() },
{ label: t.sentences, value: stats.sentences.toLocaleString() },
{ label: t.paragraphs, value: stats.paragraphs.toLocaleString() },
{ label: t.lines, value: stats.lines.toLocaleString() },
{ label: t.reading, value: `${readMin} ${t.min}` },
];

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7">
{cards.map(c => (
<div key={c.label} className="border-2 border-border bg-muted p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{c.value}</div>
<div className="text-xs text-muted-foreground">{c.label}</div>
</div>
))}
</div>

<TextArea value={text} onChange={e => setText(e.target.value)} rows={12} placeholder={t.placeholder} monospace={false} />
</div>
);
}
98 changes: 98 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,55 @@ const en: Record<string, ToolSeoContent> = {
{ q: 'Is this official tax advice?', a: 'No. It is a calculation helper. Always confirm the correct rates and articles for your situation with a tax professional.' },
],
},
'word-counter': {
title: 'Free Word Counter — Count Words, Characters & Reading Time',
description: 'A free online word counter: count words, characters (with and without spaces), sentences, paragraphs, lines and reading time as you type. Runs in your browser — nothing is uploaded.',
intro: 'This free word counter counts words, characters (with and without spaces), sentences, paragraphs, lines and estimated reading time live as you type or paste. It runs entirely in your browser, so your text is never uploaded — safe for drafts, essays and confidential writing.',
howTo: [
'Type or paste your text into the box.',
'Watch the word and character counts update instantly.',
'Check sentences, paragraphs, lines and reading time.',
],
faqs: [
{ q: 'Is my text uploaded to a server?', a: 'No. All counting happens locally in your browser, so your text never leaves your device.' },
{ q: 'How is reading time estimated?', a: 'Reading time is based on about 200 words per minute, a common average for silent reading.' },
{ q: 'How are words counted?', a: 'Words are runs of non-whitespace characters separated by spaces, tabs or line breaks — the same way most editors count them.' },
{ q: 'Does it work offline?', a: 'Yes. GoodWebTools is a PWA, so the counter keeps working with no internet connection.' },
],
},
'case-converter': {
title: 'Free Case Converter — UPPERCASE, lowercase, Title & camelCase',
description: 'Convert text between UPPERCASE, lowercase, Title Case, Sentence case, camelCase, PascalCase, snake_case, kebab-case and CONSTANT_CASE. Free and in your browser.',
intro: 'This free case converter changes your text between UPPERCASE, lowercase, Title Case, Sentence case, camelCase, PascalCase, snake_case, kebab-case and CONSTANT_CASE. Everything runs in your browser, so your text is never uploaded.',
howTo: [
'Type or paste your text.',
'Click the case you want — UPPER, lower, Title, camel, snake, kebab and more.',
'Copy the converted result.',
],
faqs: [
{ q: 'Is my text uploaded?', a: 'No. The conversion runs entirely in your browser; nothing is sent to a server.' },
{ q: 'What is the difference between camelCase and PascalCase?', a: 'Both join words with no spaces; camelCase starts lowercase (myVariableName) while PascalCase capitalises the first letter too (MyVariableName).' },
{ q: 'Does snake_case handle spaces and hyphens?', a: 'Yes. The converter splits your text into words on spaces, hyphens, underscores and camelCase boundaries before rejoining in the chosen style.' },
{ q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' },
],
},
'text-cleanup': {
title: 'Free Text Cleaner — Remove Line Breaks, Blank Lines & HTML',
description: 'Clean up messy text online: trim whitespace, collapse spaces, remove blank lines or line breaks, strip HTML, remove accents, and dedupe or sort lines. Runs in your browser.',
intro: 'This free text cleaner tidies messy text: trim each line, collapse repeated spaces, remove blank lines or line breaks, strip HTML tags, remove accents, and remove duplicate or sort lines. Pick the operations you want and it runs entirely in your browser.',
howTo: [
'Paste your messy text into the box.',
'Tick the cleanup operations you want to apply.',
'The cleaned text updates live on the right.',
'Copy or download the result.',
],
faqs: [
{ q: 'Is my text uploaded?', a: 'No. Every cleanup operation runs locally in your browser; your text never leaves your device.' },
{ q: 'Can I combine several operations?', a: 'Yes. Tick as many as you like — they are applied in order, so you can trim, collapse spaces and remove blank lines in one pass.' },
{ q: 'What does "strip HTML" do?', a: 'It removes HTML tags like <b> or <p>, leaving just the readable text.' },
{ q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' },
],
},
'compare-lists': {
title: 'Compare Two Lists — Merge, Dedupe & Diff Lines',
description: 'Compare two lists of lines online: merge and remove duplicates, subtract one list from another, or find common lines. Free, private and instant — nothing is uploaded.',
Expand Down Expand Up @@ -1996,6 +2045,55 @@ const id: Record<string, ToolSeoContent> = {
{ q: 'Apakah ini nasihat pajak resmi?', a: 'Bukan. Ini tool bantu hitung. Selalu konfirmasi tarif dan pasal yang benar untuk situasi Anda dengan profesional pajak.' },
],
},
'word-counter': {
title: 'Penghitung Kata Gratis — Hitung Kata, Karakter & Waktu Baca',
description: 'Penghitung kata online gratis: hitung kata, karakter (dengan dan tanpa spasi), kalimat, paragraf, baris, dan waktu baca saat Anda mengetik. Berjalan di browser Anda — tidak ada yang diunggah.',
intro: 'Tool penghitung kata gratis ini menghitung kata, karakter (dengan dan tanpa spasi), kalimat, paragraf, baris, dan perkiraan waktu baca secara langsung saat Anda mengetik atau menempel. Berjalan sepenuhnya di browser Anda, jadi teks tidak pernah diunggah — aman untuk draf, esai, dan tulisan rahasia.',
howTo: [
'Ketik atau tempel teks Anda ke dalam kotak.',
'Lihat jumlah kata dan karakter diperbarui seketika.',
'Periksa kalimat, paragraf, baris, dan waktu baca.',
],
faqs: [
{ q: 'Apakah teks saya diunggah ke server?', a: 'Tidak. Semua penghitungan terjadi lokal di browser Anda, jadi teks tidak pernah meninggalkan perangkat.' },
{ q: 'Bagaimana waktu baca diperkirakan?', a: 'Waktu baca didasarkan pada sekitar 200 kata per menit, rata-rata umum untuk membaca dalam hati.' },
{ q: 'Bagaimana kata dihitung?', a: 'Kata adalah rangkaian karakter non-spasi yang dipisahkan oleh spasi, tab, atau jeda baris — sama seperti kebanyakan editor menghitungnya.' },
{ q: 'Apakah bekerja offline?', a: 'Ya. GoodWebTools adalah PWA, jadi penghitung tetap berjalan tanpa koneksi internet.' },
],
},
'case-converter': {
title: 'Konverter Case Gratis — UPPERCASE, lowercase, Title & camelCase',
description: 'Ubah teks antara UPPERCASE, lowercase, Title Case, Sentence case, camelCase, PascalCase, snake_case, kebab-case, dan CONSTANT_CASE. Gratis dan di browser Anda.',
intro: 'Tool konverter case gratis ini mengubah teks Anda antara UPPERCASE, lowercase, Title Case, Sentence case, camelCase, PascalCase, snake_case, kebab-case, dan CONSTANT_CASE. Semuanya berjalan di browser Anda, jadi teks tidak pernah diunggah.',
howTo: [
'Ketik atau tempel teks Anda.',
'Klik case yang Anda inginkan — UPPER, lower, Title, camel, snake, kebab, dan lainnya.',
'Salin hasil konversinya.',
],
faqs: [
{ q: 'Apakah teks saya diunggah?', a: 'Tidak. Konversi berjalan sepenuhnya di browser Anda; tidak ada yang dikirim ke server.' },
{ q: 'Apa beda camelCase dan PascalCase?', a: 'Keduanya menggabungkan kata tanpa spasi; camelCase diawali huruf kecil (myVariableName) sedangkan PascalCase juga mengapitalkan huruf pertama (MyVariableName).' },
{ q: 'Apakah snake_case menangani spasi dan tanda hubung?', a: 'Ya. Konverter memecah teks menjadi kata pada spasi, tanda hubung, garis bawah, dan batas camelCase sebelum menggabungkannya dalam gaya yang dipilih.' },
{ q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' },
],
},
'text-cleanup': {
title: 'Pembersih Teks Gratis — Hapus Jeda Baris, Baris Kosong & HTML',
description: 'Bersihkan teks berantakan online: rapikan spasi, gabungkan spasi, hapus baris kosong atau jeda baris, hapus HTML, hapus aksen, serta hapus duplikat atau urutkan baris. Berjalan di browser Anda.',
intro: 'Tool pembersih teks gratis ini merapikan teks berantakan: rapikan tiap baris, gabungkan spasi berulang, hapus baris kosong atau jeda baris, hapus tag HTML, hapus aksen, serta hapus baris duplikat atau urutkan baris. Pilih operasi yang Anda inginkan dan semuanya berjalan di browser Anda.',
howTo: [
'Tempel teks berantakan Anda ke dalam kotak.',
'Centang operasi pembersihan yang ingin diterapkan.',
'Teks bersih diperbarui langsung di sebelah kanan.',
'Salin atau unduh hasilnya.',
],
faqs: [
{ q: 'Apakah teks saya diunggah?', a: 'Tidak. Setiap operasi pembersihan berjalan lokal di browser Anda; teks tidak pernah meninggalkan perangkat.' },
{ q: 'Bisakah menggabungkan beberapa operasi?', a: 'Ya. Centang sebanyak yang Anda mau — diterapkan berurutan, jadi Anda bisa merapikan, menggabungkan spasi, dan menghapus baris kosong dalam satu langkah.' },
{ q: 'Apa fungsi "hapus tag HTML"?', a: 'Menghapus tag HTML seperti <b> atau <p>, menyisakan hanya teks yang dapat dibaca.' },
{ q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' },
],
},
'compare-lists': {
title: 'Bandingkan Dua Daftar — Gabung, Hapus Duplikat & Diff',
description: 'Bandingkan dua daftar baris secara online: gabung dan hapus duplikat, kurangi satu daftar dari yang lain, atau temukan baris yang sama. Gratis, privat, instan — tidak ada yang diunggah.',
Expand Down
Loading
Loading