Skip to content

Commit 7a9b4b7

Browse files
committed
refactor(docs): replace worker with async main-thread extraction
- Remove extract.worker.ts and worker-client.ts - Add extract-client.ts using Duckling().extractAsync() on main thread - Add shared parsers.ts module (deduplicate registry) - Rewrite App.tsx: renderMapAsync + AbortController, live elapsed timer, textarea disabled during parsing, range-based entity grouping in hovercard, surface parse errors with message detail - Simplify AnnotatedText to accept segments prop - Simplify types.ts (remove WorkerRequest/WorkerResponse) - Bump ts-duckling dependency to ^0.3.0
1 parent 6eca7be commit 7a9b4b7

10 files changed

Lines changed: 233 additions & 238 deletions

File tree

docs/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"license": "ISC",
1414
"dependencies": {
1515
"@claudiu-ceia/combine": "npm:@jsr/claudiu-ceia__combine@^0.2.8",
16-
"@claudiu-ceia/ts-duckling": "npm:@jsr/claudiu-ceia__ts-duckling@^0.0.15",
16+
"@claudiu-ceia/ts-duckling": "npm:@jsr/claudiu-ceia__ts-duckling@^0.3.0",
1717
"@mozilla/readability": "^0.6.0",
1818
"react": "^19.2.4",
1919
"react-dom": "^19.2.4"

docs/src/App.tsx

Lines changed: 140 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
1-
import { useState, useCallback, useRef, useEffect } from "react";
1+
import { useState, useCallback, useRef, useEffect, type ReactNode } from "react";
2+
import {
3+
Duckling,
4+
type RenderMapFn,
5+
} from "@claudiu-ceia/ts-duckling";
26
import { Header } from "./components/Header";
37
import { ParserSidebar } from "./components/ParserSidebar";
48
import { AnnotatedText } from "./components/AnnotatedText";
59
import { Hovercard } from "./components/Hovercard";
6-
import { extract, stopWorker, type EntityResult } from "./worker-client";
10+
import { extract, type ExtractResult } from "./extract-client";
11+
import type { EntityResult } from "./types";
712
import { PARSER_PRIORITY, PRESETS, ALL_IDS } from "./registry";
8-
import { fmtDuration, loadSelection, saveSelection } from "./utils";
13+
import { fmtDuration, loadSelection, saveSelection, kindClasses } from "./utils";
914
import { loadUrlText } from "./fetch-url";
15+
import { registry } from "./parsers";
1016

1117
export function App() {
1218
const [selected, setSelected] = useState(() => loadSelection(ALL_IDS));
1319
const [input, setInput] = useState("");
1420
const [entities, setEntities] = useState<EntityResult[]>([]);
21+
const [segments, setSegments] = useState<(string | ReactNode)[]>([]);
1522
const [timing, setTiming] = useState("");
1623
const [status, setStatus] = useState("");
1724
const [spinning, setSpinning] = useState(false);
@@ -22,46 +29,143 @@ export function App() {
2229
const [url, setUrl] = useState("");
2330
const [loading, setLoading] = useState(false);
2431

25-
// Hovercard state
2632
const [hovercardEntities, setHovercardEntities] = useState<EntityResult[]>([]);
2733
const [hovercardAnchor, setHovercardAnchor] = useState<HTMLElement | null>(null);
2834

29-
const reqIdRef = useRef(0);
35+
const abortRef = useRef<AbortController | null>(null);
3036
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
37+
const inputSnapshotRef = useRef("");
38+
const elapsedRef = useRef<ReturnType<typeof setInterval>>(undefined);
39+
const [elapsed, setElapsed] = useState("");
3140

3241
const onSelectionChange = useCallback((next: Set<string>) => {
3342
setSelected(next);
3443
saveSelection(next);
3544
}, []);
3645

37-
const doExtract = useCallback(() => {
46+
const cancelExtract = useCallback(() => {
47+
abortRef.current?.abort();
48+
abortRef.current = null;
49+
}, []);
50+
51+
// Build renderMapAsync callback that produces highlighted React spans
52+
const buildSegments = useCallback(
53+
async (text: string, allEntities: EntityResult[], ids: string[]) => {
54+
const parsers = ids.map((id) => registry[id]).filter(Boolean);
55+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
56+
const d = parsers.length > 0 ? Duckling(parsers as any) : Duckling();
57+
58+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
59+
const mapFn: RenderMapFn<any, ReactNode> = ({
60+
entity,
61+
children,
62+
}: { entity: EntityResult; children: (string | ReactNode)[] }) => {
63+
// Collect all entities whose range overlaps this entity's range
64+
const overlapping = allEntities.filter(
65+
(e) => e.start >= entity.start && e.end <= entity.end,
66+
);
67+
const hasChildren = overlapping.length > 1;
68+
69+
return (
70+
<span
71+
key={`${entity.start}-${entity.end}-${entity.kind}`}
72+
className={`ent relative cursor-pointer rounded-md px-1 py-0.5 text-slate-900 outline-1 ${kindClasses(entity.kind)}${hasChildren ? " border-b-2 border-dashed border-slate-400/40" : ""}`}
73+
onClick={(ev: React.MouseEvent<HTMLSpanElement>) => {
74+
ev.stopPropagation();
75+
// Show all entities contained within this span
76+
const group = allEntities.filter(
77+
(e) => e.start >= entity.start && e.end <= entity.end,
78+
);
79+
group.sort(
80+
(a, b) => (b.end - b.start) - (a.end - a.start),
81+
);
82+
setHovercardEntities(group);
83+
setHovercardAnchor(ev.currentTarget);
84+
}}
85+
>
86+
{children}
87+
</span>
88+
);
89+
};
90+
91+
return await d.renderMapAsync<ReactNode>(text, mapFn);
92+
},
93+
[],
94+
);
95+
96+
const doExtract = useCallback(async () => {
3897
if (!input.trim()) {
3998
setEntities([]);
99+
setSegments([]);
40100
setTiming("");
101+
setElapsed("");
41102
setStatus("");
42103
setSpinning(false);
43104
return;
44105
}
45106

46-
const id = ++reqIdRef.current;
107+
cancelExtract();
108+
const controller = new AbortController();
109+
abortRef.current = controller;
110+
111+
// Snapshot the input so stale results don't override later edits
112+
inputSnapshotRef.current = input;
113+
47114
const max = fullText ? 0 : Math.max(500, maxChars);
48115
const ids = PARSER_PRIORITY.filter((p) => selected.has(p));
49116

50-
stopWorker();
51117
setStatus("Parsing…");
52118
setSpinning(true);
53119
setHovercardEntities([]);
54120
setHovercardAnchor(null);
55121

56-
extract({ reqId: id, text: input, ids, maxChars: max }, (resp) => {
57-
if (resp.reqId !== id) return;
58-
setEntities(resp.entities);
59-
const suffix = resp.truncated ? ` (prefix ${resp.length} chars)` : "";
60-
setTiming(`${fmtDuration(resp.ms)}${suffix}`);
122+
// Live elapsed timer
123+
const t0 = performance.now();
124+
clearInterval(elapsedRef.current);
125+
setElapsed("0ms");
126+
elapsedRef.current = setInterval(() => {
127+
setElapsed(fmtDuration(performance.now() - t0));
128+
}, 50);
129+
130+
try {
131+
const result: ExtractResult = await extract({
132+
text: input,
133+
ids,
134+
maxChars: max,
135+
signal: controller.signal,
136+
});
137+
138+
if (controller.signal.aborted) return;
139+
140+
// If the user changed input while we were parsing, discard stale results
141+
if (inputSnapshotRef.current !== input) return;
142+
143+
setEntities(result.entities);
144+
145+
const truncatedInput = max > 0 && input.length > max
146+
? input.slice(0, max)
147+
: input;
148+
const segs = await buildSegments(truncatedInput, result.entities, ids);
149+
if (controller.signal.aborted) return;
150+
151+
setSegments(segs);
152+
const suffix = result.truncated
153+
? ` (prefix ${result.length} chars)`
154+
: "";
155+
setTiming(`${fmtDuration(result.ms)}${suffix}`);
61156
setStatus("");
157+
} catch (err) {
158+
if (err instanceof DOMException && err.name === "AbortError") return;
159+
const msg = err instanceof Error ? err.message : String(err);
160+
console.error("[ts-duckling] extraction error:", err);
161+
setStatus(`Error: ${msg.length > 120 ? msg.slice(0, 120) + "…" : msg}`);
162+
setTimeout(() => setStatus(""), 5000);
163+
} finally {
164+
clearInterval(elapsedRef.current);
165+
setElapsed("");
62166
setSpinning(false);
63-
});
64-
}, [input, selected, maxChars, fullText]);
167+
}
168+
}, [input, selected, maxChars, fullText, cancelExtract, buildSegments]);
65169

66170
// Debounced auto-extract
67171
useEffect(() => {
@@ -80,7 +184,7 @@ export function App() {
80184
setLoading(true);
81185
setStatus("Fetching…");
82186
setSpinning(true);
83-
stopWorker();
187+
cancelExtract();
84188
try {
85189
const result = await loadUrlText(url);
86190
setInput(result.text);
@@ -89,32 +193,32 @@ export function App() {
89193
setTimeout(() => setStatus(""), 1500);
90194
} catch {
91195
setStatus("Load failed (likely CORS)");
196+
setSpinning(false);
92197
setTimeout(() => setStatus(""), 2500);
93198
} finally {
94199
setLoading(false);
95-
setSpinning(false);
200+
// Don't reset spinning here — doExtract will be triggered by setInput
201+
// and will manage spinning on its own
96202
}
97203
};
98204

99-
const handleEntityClick = (entity: EntityResult, el: HTMLElement) => {
100-
const group = entities.filter((e) => e.start === entity.start);
101-
group.sort((a, b) => (b.end - b.start) - (a.end - a.start));
102-
setHovercardEntities(group);
103-
setHovercardAnchor(el);
104-
};
105-
106205
const handleStop = () => {
107-
stopWorker();
206+
cancelExtract();
207+
clearInterval(elapsedRef.current);
208+
setElapsed("");
108209
setStatus("Stopped");
109210
setSpinning(false);
110211
setTimeout(() => setStatus(""), 900);
111212
};
112213

113214
const handleClear = () => {
114-
stopWorker();
215+
cancelExtract();
216+
clearInterval(elapsedRef.current);
217+
setElapsed("");
115218
setInput("");
116219
setUrl("");
117220
setEntities([]);
221+
setSegments([]);
118222
setTiming("");
119223
setStatus("");
120224
setSpinning(false);
@@ -233,8 +337,9 @@ export function App() {
233337
<textarea
234338
value={input}
235339
onChange={(e) => { setInput(e.target.value); setPreset(""); }}
340+
disabled={spinning}
236341
spellCheck={false}
237-
className="mt-4 h-56 w-full resize-y rounded-2xl border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 shadow-sm focus:border-teal-300 focus:outline-none focus:ring-4 focus:ring-teal-200/40"
342+
className="mt-4 h-56 w-full resize-y rounded-2xl border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 shadow-sm focus:border-teal-300 focus:outline-none focus:ring-4 focus:ring-teal-200/40 disabled:bg-slate-50 disabled:text-slate-500 disabled:cursor-not-allowed"
238343
placeholder="Try: Email me at no-reply+foo@some.domain.dev, call +14155552671, visit https://duckling.deno.dev/, SSN 123-45-6789, CC 4242 4242 4242 4242"
239344
/>
240345
</div>
@@ -246,7 +351,13 @@ export function App() {
246351
<h2 className="text-sm font-semibold tracking-wide text-slate-900">Preview</h2>
247352
<div className="flex items-center gap-3 text-sm text-slate-500">
248353
<span>{entities.length} matches</span>
249-
{timing && (
354+
{elapsed && (
355+
<>
356+
<span className="text-slate-300"></span>
357+
<span className="font-mono text-teal-600 tabular-nums">{elapsed}</span>
358+
</>
359+
)}
360+
{timing && !elapsed && (
250361
<>
251362
<span className="text-slate-300"></span>
252363
<span className="font-mono text-slate-600">{timing}</span>
@@ -268,11 +379,7 @@ export function App() {
268379
Annotated text
269380
</div>
270381
<div className="mt-2">
271-
<AnnotatedText
272-
text={input}
273-
entities={entities}
274-
onEntityClick={handleEntityClick}
275-
/>
382+
<AnnotatedText segments={segments.length > 0 ? segments : [input]} />
276383
</div>
277384

278385
{showJson && (
Lines changed: 4 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,13 @@
1-
import type { EntityResult } from "../types";
2-
import { kindClasses } from "../utils";
1+
import type { ReactNode } from "react";
32

43
type Props = {
5-
text: string;
6-
entities: EntityResult[];
7-
limit?: number;
8-
onEntityClick: (entity: EntityResult, el: HTMLElement) => void;
4+
segments: (string | ReactNode)[];
95
};
106

11-
/** Entities to show as annotations — pick shortest per start offset, suppress inner quantities. */
12-
function displayEntities(entities: EntityResult[]): EntityResult[] {
13-
const ATOMIC = new Set(["ip", "url", "email", "uuid", "phone"]);
14-
15-
// Group by start
16-
const byStart = new Map<number, EntityResult[]>();
17-
for (const e of entities) {
18-
const arr = byStart.get(e.start) ?? [];
19-
arr.push(e);
20-
byStart.set(e.start, arr);
21-
}
22-
23-
// Pick one representative per start
24-
const display: EntityResult[] = [];
25-
for (const [, arr] of [...byStart.entries()].sort(([a], [b]) => a - b)) {
26-
const atomic = arr.filter((e) => ATOMIC.has(e.kind));
27-
const pool = atomic.length ? atomic : arr;
28-
const chosen = [...pool].sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
29-
display.push(chosen);
30-
}
31-
32-
// Remove quantity spans that sit inside atomic entities
33-
const atomicSpans = entities
34-
.filter((e) => ATOMIC.has(e.kind))
35-
.map((e) => ({ start: e.start, end: e.end }));
36-
37-
return display.filter((e) => {
38-
if (e.kind !== "quantity") return true;
39-
return !atomicSpans.some((a) => e.start >= a.start && e.end <= a.end);
40-
});
41-
}
42-
43-
export function AnnotatedText({ text, entities, limit, onEntityClick }: Props) {
44-
const view = typeof limit === "number" ? text.slice(0, limit) : text;
45-
const shown = displayEntities(entities);
46-
const sorted = [...shown].sort((a, b) => a.start - b.start);
47-
48-
const parts: React.ReactNode[] = [];
49-
let cursor = 0;
50-
51-
for (const e of sorted) {
52-
if (e.start < cursor || e.start > view.length) continue;
53-
const end = Math.min(e.end, view.length);
54-
55-
if (e.start > cursor) {
56-
parts.push(view.slice(cursor, e.start));
57-
}
58-
59-
// Count how many total matches exist at this start offset
60-
const groupCount = entities.filter((x) => x.start === e.start).length;
61-
62-
parts.push(
63-
<span
64-
key={`${e.start}-${e.end}-${e.kind}`}
65-
className={`ent relative cursor-pointer rounded-md px-1 py-0.5 text-slate-900 outline-1 ${kindClasses(e.kind)}`}
66-
onClick={(ev) => onEntityClick(e, ev.currentTarget)}
67-
>
68-
{view.slice(e.start, end)}
69-
{groupCount > 1 && (
70-
<span className="pointer-events-none absolute -right-1 -top-1 h-2 w-2 rounded-full bg-slate-400/80" />
71-
)}
72-
</span>,
73-
);
74-
cursor = end;
75-
}
76-
77-
if (cursor < view.length) {
78-
parts.push(view.slice(cursor));
79-
}
80-
7+
export function AnnotatedText({ segments }: Props) {
818
return (
829
<div className="min-h-30 rounded-2xl border border-slate-200 bg-white p-4 font-mono text-sm leading-relaxed text-slate-900 whitespace-pre-wrap">
83-
{parts}
10+
{segments}
8411
</div>
8512
);
8613
}

0 commit comments

Comments
 (0)