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
61 changes: 61 additions & 0 deletions docs/superpowers/plans/2026-08-13-regex-tester.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Regex Tester Implementation Plan

> **For agentic workers:** implement task-by-task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Ship a client-side regex tester with live highlighting, capture groups, flags, and per-language code snippets + flavor warnings.

**Architecture:** Pure lib `regex.lib.ts` (run + highlight + snippet + warnings); thin island `RegexTester.tsx`; registry + SEO. Native `RegExp` only — no new deps.

**Tech Stack:** Astro + React island, Vitest, native JS `RegExp`.

## Global Constraints

- 100% client-side; matching on native `RegExp`.
- New tool `status: 'beta'`; Dev category.
- Bahasa copy uses "tool" loanword, never "alat".
- Commit under the personal noreply identity; no AI-attribution trailers; no absolute machine paths in committed files.

---

### Task 1: Pure lib `regex.lib.ts` (TDD)

**Files:** Create `src/tools/dev/regex.lib.ts`, Test `src/tools/dev/regex.lib.test.ts`.

**Interfaces produced:**
- `runRegex(pattern, flags, subject): { matches: RegexMatch[]; error?: string; truncated?: boolean }`
- `RegexMatch = { index: number; value: string; groups: (string|undefined)[]; named: Record<string,string|undefined> }`
- `escapeHtml(s): string`
- `highlightHtml(subject, matches): string`
- `codeSnippet(lang, pattern, flags): string`
- `flavorWarnings(pattern, flags, lang): string[]`
- `FLAGS`, `LANGUAGES`, type `RegexLang`

- [ ] Write failing tests covering: match/no-match, numbered + named groups, flags g/i/m/s, global iteration + zero-width guard, invalid pattern → error; `highlightHtml` escaping + `<mark>`; `codeSnippet` for each of the 7 langs (pattern encoding + flag map) via `it.each`; `flavorWarnings` (Go lookbehind → warns, Go backref → warns, Python named group → warns, Ruby `s`-flag swap → warns, plain pattern → no warnings).
- [ ] Run — confirm fail (module not found).
- [ ] Implement lib.
- [ ] Run — confirm pass.

### Task 2: Island `RegexTester.tsx`

**Files:** Create `src/islands/dev/RegexTester.tsx`.

- [ ] Pattern `TextArea`, flag toggle-chips (segmented), subject `TextArea`. `useMemo` results on `[pattern, flags, subject]`. Highlighted `<pre>` via `dangerouslySetInnerHTML(highlightHtml)`. Match list (count, index, value, groups, named). Language `<select>` → `codeSnippet` in read-only block + `CopyButton`; `flavorWarnings` shown. i18n TR en+id. Error/empty/no-match states. SSR-safe.

### Task 3: Register + SEO

**Files:** Modify `src/registry/tools.ts`, `src/registry/tool-seo.ts`.

- [ ] Import `Regex` icon; add the ToolDef.
- [ ] Add EN + ID `regex-tester` SEO entries.

### Task 4: Verify loop

- [ ] `npx vitest run` green.
- [ ] `npm run lint` 0 errors.
- [ ] `npm run build` succeeds; `/tools/regex-tester` built (EN + ID).
- [ ] Hand review: dangerouslySetInnerHTML input is always escaped first; zero-width/global infinite-loop guard; huge-input cap; reference-identity of derived props; error/empty paths.

### Task 5: Ship dev → prod

- [ ] Commit on `feat/regex-tester`, PR → develop, CI green, merge.
- [ ] Promote develop → main (`--admin`), confirm Cloudflare prod build, verify live URL.
83 changes: 83 additions & 0 deletions docs/superpowers/specs/2026-08-13-regex-tester-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Regex Tester — Design

**Date:** 2026-08-13
**Category:** Dev
**Tool id:** `regex-tester`

## Goal

A live regular-expression tester: type a pattern + flags, test it against sample text, see matches highlighted inline, inspect capture groups (numbered + named), and get the **equivalent code snippet** for the language you actually use — plus honest warnings when your pattern relies on syntax that behaves differently in that language.

## Honest engine model (decided with user)

Browsers can only natively execute **JavaScript's `RegExp`**. Rather than pretend otherwise, matching always runs on the JS engine (zero deps, instant, private). The multi-language value comes from two pure, deterministic features layered on top:

1. **Code-snippet generation** — render idiomatic "find all matches" code for the chosen language, embedding the user's pattern + mapped flags.
2. **Flavor warnings** — detect pattern/flag constructs whose semantics differ in the target language and warn precisely (e.g. Go/RE2 has no lookbehind or backreferences; Python/Go name groups with `(?P<…>)`; Ruby's `m` flag means dotall, not multiline).

Languages covered: **JavaScript, Python, PHP (PCRE), Java, Go, C#/.NET, Ruby.**

Everything is client-side. Nothing is uploaded.

## Architecture

Mirrors the `sql-format` Dev tool (text input + option controls + live derived output, pure logic in a lib).

### Pure lib — `src/tools/dev/regex.lib.ts`

- `runRegex(pattern: string, flags: string, subject: string): RegexResult`
- `RegexResult = { matches: RegexMatch[]; error?: string; truncated?: boolean }`
- `RegexMatch = { index: number; value: string; groups: (string | undefined)[]; named: Record<string, string | undefined> }`
- `new RegExp(pattern, flags)` in try/catch → `{ matches: [], error }` on invalid pattern/flags.
- Global/sticky (`g`/`y`): iterate `exec`; guard zero-width matches by advancing `lastIndex`. Cap at `MAX_MATCHES = 10000` → set `truncated`.
- `escapeHtml(s: string): string` — pure (`& < > "`).
- `highlightHtml(subject: string, matches: RegexMatch[]): string` — escape subject, wrap non-zero-width match ranges in `<mark class="rx-hl">` / alternating `rx-hl-alt` for adjacent matches; injected via `dangerouslySetInnerHTML` in a `<pre>` (mirrors `CodeBlock`).
- `FLAGS: { flag: string; label: string }[]` — g, i, m, s, u, y.
- `LANGUAGES: { id: RegexLang; label: string }[]` — the 7 flavors.
- `codeSnippet(lang: RegexLang, pattern: string, flags: string): string` — pure; per-language string-literal encoding (JS `/…/`, Python raw `r"…"`, PHP `/…/` with `/` escaped, Java double-quoted with `\`/`"` escaped, Go backtick raw with double-quoted fallback, C# verbatim `@"…"` with `"`→`""`, Ruby `/…/`) + flag mapping to each language's API.
- `flavorWarnings(pattern: string, flags: string, lang: RegexLang): string[]` — pure heuristics: named-group syntax (Python/Go), lookbehind/lookahead + backreferences (Go/RE2), Ruby `m`/`s` flag-semantics swap, sticky `y` outside JS.

All of the above are unit-tested.

### Island — `src/islands/dev/RegexTester.tsx` (default export)

- `TextArea` for pattern (single-ish line) + a segmented row of flag toggle-chips (`aria-pressed`) + `TextArea` for the test string.
- Live results via `useMemo` on `[pattern, flags, subject]` (synchronous — no dynamic import needed).
- **Highlighted subject** rendered from `highlightHtml` into a `<pre>` via `dangerouslySetInnerHTML`.
- **Match list**: count, each match's index + value + numbered groups + named groups. Empty/error/ no-match states.
- **Language panel**: native `<select>` (7 langs) → `codeSnippet` shown in a read-only block with `CopyButton`, plus any `flavorWarnings` as `Alert`s / muted notes.
- i18n `TR: Record<Lang, {...}>`; signature `export default function RegexTester({ lang = 'en' }: { lang?: Lang })`.
- SSR-safe (RegExp only touched inside `useMemo`/handlers, which is fine, but no module-scope DOM access).

### Registry — `src/registry/tools.ts`

```ts
{
id: 'regex-tester',
name: 'Regex Tester',
category: 'Dev',
route: '/tools/regex-tester',
keywords: ['regex', 'regexp', 'regular expression', 'test', 'match', 'pattern', 'pcre', 'javascript', 'python', 'java', 'go'],
icon: Regex,
summary: 'Test regular expressions with live highlighting and per-language code',
load: () => import('@/islands/dev/RegexTester'),
status: 'beta'
},
```
`Regex` imported from `lucide-react` (the icon exists; add to the import list).

### SEO — `src/registry/tool-seo.ts`

Add `regex-tester` to both the EN block (~line 9+) and the ID block (~line 1534+). Keyword targets: "regex tester", "test regular expression online", "regex match highlighter", "Python/Java/Go regex". Bahasa uses "tool" loanword, never "alat".

### No new dependency, no globIgnores change

Native `RegExp`; nothing heavy to code-split or exclude from the PWA precache.

## Testing

`src/tools/dev/regex.lib.test.ts` — `runRegex` (match, no-match, capture groups, named groups, each flag, global iteration, zero-width guard, invalid pattern → error), `highlightHtml` (escaping + mark wrapping), `codeSnippet` (per-language literal encoding + flag mapping, `it.each`), `flavorWarnings` (Go lookbehind/backref, Python/Go named groups, Ruby flag swap).

## Definition of done

Spec+plan committed · `regex.lib.ts` unit-tested · vitest + lint + build green · `/tools/regex-tester` builds (EN + ID) · merged to develop · promoted to main · Cloudflare prod build green · live URL verified · user told about PWA hard-refresh.
206 changes: 206 additions & 0 deletions src/islands/dev/RegexTester.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { useMemo, useState } from 'react';
import { TextArea } from '@/components/ui/TextArea';
import { Alert } from '@/components/ui/Alert';
import { CopyButton } from '@/components/ui/CopyButton';
import {
runRegex,
highlightHtml,
codeSnippet,
flavorWarnings,
FLAGS,
LANGUAGES,
type RegexLang,
} from '@/tools/dev/regex.lib';
import type { Lang } from '@/i18n/config';

const EXAMPLE_PATTERN = '(?<user>[\\w.]+)@(?<domain>[\\w.]+)';
const EXAMPLE_SUBJECT =
'Contact: alice@example.com, bob@work.co.id\nSupport: team@goodwebtools.com\nInvalid: not-an-email @ nope';

const TR: Record<Lang, {
intro: string;
pattern: string;
flags: string;
testString: string;
matchesN: (n: number) => string;
noMatches: string;
truncated: string;
groups: string;
named: string;
language: string;
equivalent: string;
notes: string;
}> = {
en: {
intro: 'Test a regular expression against sample text with live match highlighting, then copy the equivalent code for your language. Everything runs in your browser.',
pattern: 'Regular expression',
flags: 'Flags',
testString: 'Test string',
matchesN: (n) => `${n} ${n === 1 ? 'match' : 'matches'}`,
noMatches: 'No matches.',
truncated: 'Showing the first 10,000 matches.',
groups: 'Groups',
named: 'Named',
language: 'Language',
equivalent: 'Equivalent code',
notes: 'Flavor notes',
},
id: {
intro: 'Uji ekspresi reguler terhadap teks contoh dengan sorotan kecocokan langsung, lalu salin kode setara untuk bahasa Anda. Semuanya berjalan di browser Anda.',
pattern: 'Ekspresi reguler',
flags: 'Flag',
testString: 'Teks uji',
matchesN: (n) => `${n} kecocokan`,
noMatches: 'Tidak ada kecocokan.',
truncated: 'Menampilkan 10.000 kecocokan pertama.',
groups: 'Grup',
named: 'Bernama',
language: 'Bahasa',
equivalent: 'Kode setara',
notes: 'Catatan flavor',
},
};

export default function RegexTester({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [pattern, setPattern] = useState(EXAMPLE_PATTERN);
const [flags, setFlags] = useState('g');
const [subject, setSubject] = useState(EXAMPLE_SUBJECT);
const [target, setTarget] = useState<RegexLang>('python');

const result = useMemo(() => runRegex(pattern, flags, subject), [pattern, flags, subject]);
const html = useMemo(() => highlightHtml(subject, result.matches), [subject, result]);
const snippet = useMemo(() => codeSnippet(target, pattern, flags), [target, pattern, flags]);
const warnings = useMemo(() => flavorWarnings(pattern, flags, target), [pattern, flags, target]);

const toggleFlag = (f: string) => {
setFlags(prev =>
prev.includes(f)
? prev.replace(f, '')
: FLAGS.map(x => x.flag).filter(x => prev.includes(x) || x === f).join(''),
);
};

const chipClass = (active: boolean) =>
`border-2 px-3 py-1 font-mono text-sm font-medium transition-all ${
active
? 'border-border bg-accent text-accent-foreground shadow-brutal'
: 'border-border hover:shadow-brutal'
}`;

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

<div className="space-y-2">
<span className="block text-sm font-semibold">{t.pattern}</span>
<TextArea
value={pattern}
onChange={e => setPattern(e.target.value)}
rows={2}
spellCheck={false}
placeholder="\\d{4}-\\d{2}-\\d{2}"
/>
</div>

<div className="space-y-1 text-sm">
<span className="block font-semibold">{t.flags}</span>
<div className="flex flex-wrap gap-1">
{FLAGS.map(({ flag, label }) => (
<button
key={flag}
type="button"
onClick={() => toggleFlag(flag)}
aria-pressed={flags.includes(flag)}
title={label}
className={chipClass(flags.includes(flag))}
>
{flag}
</button>
))}
</div>
</div>

{result.error && <Alert variant="error">{result.error}</Alert>}

<div className="space-y-2">
<span className="block text-sm font-semibold">{t.testString}</span>
<TextArea
value={subject}
onChange={e => setSubject(e.target.value)}
rows={6}
spellCheck={false}
/>
</div>

{subject && !result.error && (
<div className="space-y-2">
<span className="text-sm font-semibold">
{t.matchesN(result.matches.length)}
{result.truncated && ` · ${t.truncated}`}
</span>
<pre
className="overflow-x-auto whitespace-pre-wrap break-words border-2 border-border bg-muted p-3 font-mono text-sm [&_mark.rx-hl]:bg-accent [&_mark.rx-hl]:text-accent-foreground [&_mark.rx-hl-alt]:bg-yellow-300 [&_mark.rx-hl-alt]:dark:bg-yellow-500 [&_mark]:rounded-sm [&_mark]:px-0.5"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)}

{result.matches.length > 0 && (
<div className="max-h-64 space-y-2 overflow-y-auto border-2 border-border p-3">
{result.matches.map((m, i) => (
<div key={i} className="border-b border-border pb-2 text-sm last:border-0 last:pb-0">
<div className="flex flex-wrap items-baseline gap-x-3">
<span className="font-mono font-bold">#{i + 1}</span>
<span className="text-muted-foreground">@{m.index}</span>
<span className="font-mono">{m.value || '(empty)'}</span>
</div>
{m.groups.length > 0 && (
<div className="mt-1 text-xs text-muted-foreground">
{t.groups}: {m.groups.map((g, gi) => `$${gi + 1}=${g ?? '∅'}`).join(' ')}
</div>
)}
{Object.keys(m.named).length > 0 && (
<div className="text-xs text-muted-foreground">
{t.named}: {Object.entries(m.named).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ')}
</div>
)}
</div>
))}
</div>
)}

{subject && !result.error && result.matches.length === 0 && (
<p className="text-sm text-muted-foreground">{t.noMatches}</p>
)}

<div className="space-y-2 border-t-2 border-border pt-4">
<div className="flex flex-wrap items-end gap-x-6 gap-y-2">
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.language}</span>
<select
value={target}
onChange={e => setTarget(e.target.value as RegexLang)}
className="border-2 border-border bg-background px-2 py-1.5 text-sm"
>
{LANGUAGES.map(l => (
<option key={l.id} value={l.id}>{l.label}</option>
))}
</select>
</label>
<span className="mr-auto text-sm font-semibold">{t.equivalent}</span>
<CopyButton value={snippet} />
</div>
<TextArea value={snippet} readOnly rows={8} spellCheck={false} />
{warnings.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t.notes}</span>
{warnings.map((warn, i) => (
<p key={i} className="text-sm text-amber-700 dark:text-amber-400">⚠ {warn}</p>
))}
</div>
)}
</div>
</div>
);
}
Loading
Loading