1010
1111<p align =" center " >
1212 A tiny, deterministic entity extractor for TypeScript.<br >
13- <b >Extract</b > structured data and <b >redact</b > PII from free-form text — no ML, no network calls.
13+ <b >Extract</b > structured data, < b >render</ b > rich highlights, and <b >redact</b > PII from free-form text — no ML, no network calls.
1414</p >
1515
1616<p align =" center " >
2323``` ts
2424import { Duckling , PIIParsers } from " @claudiu-ceia/ts-duckling" ;
2525
26- // Extract structured entities
26+ // Extract structured entities from a chat message
2727const entities = Duckling ().extract (
28- " Email me at foo@bar.com — meeting at 3pm" ,
28+ " Hey! Meet me at Times Square tomorrow at 3pm. My email is alex@company.io " ,
2929);
30- // → [{ kind: "email", value: { email: "foo@bar.com" }, ... },
31- // { kind: "time", value: { when: "...", grain: "hour" }, ... }]
30+ // → [{ kind: "location", text: "Times Square", ... },
31+ // { kind: "time", text: "tomorrow at 3pm", ... },
32+ // { kind: "email", text: "alex@company.io", ... }]
3233
3334// Redact PII in one line
34- Duckling (PIIParsers ).redact (" Email me at foo@bar.com, SSN 123-45-6789" );
35- // → "Email me at ███████████████, SSN ███████████"
35+ Duckling (PIIParsers ).redact (
36+ " Contact alex@company.io, SSN 078-05-1120, or call +14155552671" ,
37+ );
38+ // → "Contact ██████████████████, SSN ███████████, or call ████████████"
3639```
3740
3841## Overview
@@ -46,8 +49,10 @@ TypeScript and running anywhere — Deno, Node, or the browser.
4649- ** Deterministic** — same input always produces the same output
4750- ** Typed** — parser selection narrows the return type automatically
4851- ** Composable** — bring your own parsers alongside the built-in ones
49- - ** Self-contained** — no native dependencies, no runtime downloads, no network calls
50- - ** Runs everywhere** — Deno, Node.js, and browsers (see the [ live playground] ( https://claudiuceia.github.io/ts-duckling/ ) )
52+ - ** Self-contained** — no native dependencies, no runtime downloads, no network
53+ calls
54+ - ** Runs everywhere** — Deno, Node.js, and browsers (see the
55+ [ live playground] ( https://claudiuceia.github.io/ts-duckling/ ) )
5156
5257## Documentation
5358
@@ -56,14 +61,20 @@ TypeScript and running anywhere — Deno, Node, or the browser.
5661 - [ Extract entities] ( #extract-entities )
5762 - [ Pick specific parsers] ( #pick-specific-parsers )
5863 - [ Redact PII] ( #redact-pii )
64+ - [ Render entities] ( #render-entities )
65+ - [ Map entities to components] ( #map-entities-to-components )
5966 - [ Custom entities] ( #custom-entities )
6067- [ Supported entities] ( #supported-entities )
6168- [ API reference] ( #api-reference )
6269 - [ ` Duckling() ` ] ( #duckling-1 )
6370 - [ ` .extract(text) ` ] ( #extracttext )
71+ - [ ` .render(text, fn) ` ] ( #rendertext-fn )
72+ - [ ` .renderMap(text, fn) ` ] ( #rendermaptext-fn )
6473 - [ ` .redact(text, opts?) ` ] ( #redacttext-opts )
6574 - [ ` PIIParsers ` ] ( #piiparsers )
6675 - [ ` RedactOptions ` ] ( #redactoptions )
76+ - [ ` RenderFn ` ] ( #renderfn )
77+ - [ ` RenderMapFn ` ] ( #rendermapfn )
6778 - [ ` AnyEntity ` ] ( #anyentity )
6879 - [ ` PIIEntity ` ] ( #piientity )
6980- [ Caveats] ( #caveats )
@@ -99,28 +110,28 @@ Call `Duckling()` with no arguments to use **all 15 built-in parsers**:
99110``` ts
100111import { Duckling } from " @claudiu-ceia/ts-duckling" ;
101112
102- const entities = Duckling ().extract (
103- " Email me at foo@example.com and visit https://example.com tomorrow at 3pm." ,
104- );
113+ const msg =
114+ " Hey! I'll be in Germany next Friday at 5pm. Shoot me a message at alex@company.io or visit https://example.com/invite" ;
105115
106- for (const e of entities ) {
116+ for (const e of Duckling (). extract ( msg ) ) {
107117 console .log (e .kind , e .text );
108118}
109- // email foo@example.com
110- // url https://example.com
111- // time tomorrow at 3pm
119+ // location Germany
120+ // time next Friday at 5pm
121+ // email alex@company.io
122+ // url https://example.com/invite
112123```
113124
114125Each entity carries structured data:
115126
116127``` ts
117128// entities[0]
118129{
119- kind : " email " ,
120- value : { email : " foo@example.com " },
121- start : 12 ,
122- end : 27 ,
123- text : " foo@example.com "
130+ kind : " location " ,
131+ value : { location : " Germany " },
132+ start : 16 ,
133+ end : 23 ,
134+ text : " Germany "
124135}
125136```
126137
@@ -130,12 +141,12 @@ Pass an array of parsers to narrow both **what gets extracted** and **the return
130141type** :
131142
132143``` ts
133- import { Duckling , Email , URL } from " @claudiu-ceia/ts-duckling" ;
144+ import { Duckling , Email , Time , URL } from " @claudiu-ceia/ts-duckling" ;
134145
135- const entities = Duckling ([Email .parser , URL .parser ]).extract (
136- " Reach me at a@b.com or https://example .com" ,
146+ const entities = Duckling ([Email .parser , URL .parser , Time . parser ]).extract (
147+ " Ping me at alex@company.io or https://meet .com — available tomorrow at 2pm " ,
137148);
138- // entities: (EmailEntity | URLEntity)[]
149+ // entities: (EmailEntity | URLEntity | TimeEntity )[]
139150```
140151
141152### Redact PII
@@ -146,18 +157,128 @@ Use `.redact()` to replace matched entity spans with a mask character:
146157import { Duckling , PIIParsers } from " @claudiu-ceia/ts-duckling" ;
147158
148159// Redact all PII (email, phone, IP, SSN, credit card, UUID, API key)
149- Duckling (PIIParsers ).redact (" Contact foo@bar.com, SSN 078-05-1120" );
150- // → "Contact ███████████████, SSN ███████████"
160+ Duckling (PIIParsers ).redact (
161+ " Patient email: john.doe@clinic.org, SSN 078-05-1120, phone +14155552671" ,
162+ );
163+ // → "Patient email: ██████████████████████, SSN ███████████, phone ████████████"
151164
152165// Custom mask character
153166Duckling (PIIParsers ).redact (" Call +14155552671" , { mask: " X" });
154167// → "Call XXXXXXXXXXXX"
155168
156169// Redact only specific kinds
157- Duckling (PIIParsers ).redact (" foo@bar.com 123-45-6789" , { kinds: [" ssn" ] });
158- // → "foo@bar.com ███████████"
170+ Duckling (PIIParsers ).redact (
171+ " Contact john.doe@clinic.org, SSN 078-05-1120" ,
172+ { kinds: [" ssn" ] },
173+ );
174+ // → "Contact john.doe@clinic.org, SSN ███████████"
175+ ```
176+
177+ ### Render entities
178+
179+ Use ` .render() ` to replace entity spans via a callback — perfect for turning
180+ plain-text messages into HTML with highlighted or linked entities:
181+
182+ ``` ts
183+ import { Duckling } from " @claudiu-ceia/ts-duckling" ;
184+
185+ const msg =
186+ " Hey! Meet at Times Square tomorrow at 3pm, email me at alex@company.io or check https://example.com/rsvp" ;
187+
188+ const html = Duckling ().render (msg , ({ entity , children }) => {
189+ switch (entity .kind ) {
190+ case " url" :
191+ return ` <a href="${children }">${children }</a> ` ;
192+ case " email" :
193+ return ` <a href="mailto:${children }">${children }</a> ` ;
194+ default :
195+ return ` <mark data-kind="${entity .kind }">${children }</mark> ` ;
196+ }
197+ });
198+ // → 'Hey! Meet at <mark data-kind="location">Times Square</mark>
199+ // <mark data-kind="time">tomorrow at 3pm</mark>, email me at
200+ // <a href="mailto:alex@company.io">alex@company.io</a> or check
201+ // <a href="https://example.com/rsvp">https://example.com/rsvp</a>'
202+ ```
203+
204+ Nested entities (e.g. an SSN containing quantity sub-parts) are rendered
205+ inside-out — inner entities are transformed first, and the parent receives the
206+ result:
207+
208+ ``` ts
209+ import { Duckling , Quantity , SSN } from " @claudiu-ceia/ts-duckling" ;
210+
211+ Duckling ([Quantity .parser , SSN .parser ]).render (
212+ " SSN 123-45-6789" ,
213+ ({ entity , children }) => ` <${entity .kind }>${children }</${entity .kind }> ` ,
214+ );
215+ // → "SSN <ssn><quantity>123</quantity>-<quantity>45</quantity>-<quantity>6789</quantity></ssn>"
216+ ```
217+
218+ Return ` undefined ` to leave a span unchanged — useful for selective rendering:
219+
220+ ``` ts
221+ import { Duckling } from " @claudiu-ceia/ts-duckling" ;
222+
223+ // Only make URLs clickable, leave everything else as plain text
224+ Duckling ().render (
225+ " Visit https://example.com — event on next Friday at 5pm in Germany" ,
226+ ({ entity , children }) => {
227+ if (entity .kind === " url" ) return ` <a href="${children }">${children }</a> ` ;
228+ return undefined ;
229+ },
230+ );
231+ // → 'Visit <a href="https://example.com">https://example.com</a> — event on next Friday at 5pm in Germany'
232+ ```
233+
234+ ### Map entities to components
235+
236+ Use ` .renderMap() ` when you need an ** array of segments** instead of a single
237+ string — ideal for React, Preact, Solid, or any framework that renders element
238+ trees:
239+
240+ ``` tsx
241+ import { Duckling } from " @claudiu-ceia/ts-duckling" ;
242+
243+ const msg = " Hey! I'm at Times Square, email me at alex@company.io" ;
244+
245+ const segments = Duckling ().renderMap <JSX .Element >(
246+ msg ,
247+ ({ entity , children }) => (
248+ <mark key = { entity .start } data-kind = { entity .kind } >
249+ { children }
250+ </mark >
251+ ),
252+ );
253+ // → ["Hey! I'm at ", <mark data-kind="location">Times Square</mark>,
254+ // ", email me at ", <mark data-kind="email">alex@company.io</mark>]
255+
256+ // Drop it straight into a component
257+ function HighlightedMessage({ text }: { text: string }) {
258+ const segments = Duckling ().renderMap <JSX .Element >(
259+ text ,
260+ ({ entity , children }) => {
261+ switch (entity .kind ) {
262+ case " url" :
263+ return <a href = { entity .text } >{ children } </a >;
264+ case " email" :
265+ return <a href = { ` mailto:${entity .text } ` } >{ children } </a >;
266+ case " time" :
267+ return <time >{ children } </time >;
268+ default :
269+ return <mark data-kind = { entity .kind } >{ children } </mark >;
270+ }
271+ },
272+ );
273+
274+ return <p >{ segments } </p >;
275+ }
159276```
160277
278+ Like ` .render() ` , nested entities are handled automatically — child spans are
279+ mapped first, and the parent callback receives the already-mapped children as
280+ ` (string | R)[] ` .
281+
161282### Custom entities
162283
163284Define a parser that returns an ` Entity ` , then pass it to ` Duckling ` :
@@ -192,7 +313,7 @@ Custom parsers compose freely with the built-in ones:
192313import { Email } from " @claudiu-ceia/ts-duckling" ;
193314
194315const entities = Duckling ([Email .parser , Hashtag .parser ]).extract (
195- " Email a@b.com with #feedback" ,
316+ " Email alex@company.io with #feedback" ,
196317);
197318// entities: (EmailEntity | HashtagEntity)[]
198319```
@@ -222,11 +343,16 @@ const entities = Duckling([Email.parser, Hashtag.parser]).extract(
222343### ` Duckling() `
223344
224345``` ts
225- function Duckling(): { extract; redact };
226- function Duckling<T >(parsers : ParserTuple <T >): { extract; redact };
346+ function Duckling(): { extract; render; renderMap; redact };
347+ function Duckling<T >(parsers : ParserTuple <T >): {
348+ extract;
349+ render;
350+ renderMap;
351+ redact;
352+ };
227353```
228354
229- Creates an extractor/redactor pair . Without arguments, uses all 15 built-in
355+ Creates an extractor/renderer/ redactor. Without arguments, uses all 15 built-in
230356parsers and returns ` AnyEntity[] ` . When given an explicit parser array, the
231357return type narrows to the union of those entity types.
232358
@@ -239,15 +365,40 @@ extract(text: string): Entity[]
239365Scans ` text ` and returns all matched entities, each with ` kind ` , ` value ` ,
240366` start ` , ` end ` , and ` text ` fields. Entities are returned in order of appearance.
241367
368+ ### ` .render(text, fn) `
369+
370+ ``` ts
371+ render (text : string , fn : RenderFn < Entity > ): string
372+ ```
373+
374+ Extracts entities, arranges them into a span tree (wider spans parent narrower
375+ ones), and calls ` fn ` for each entity node. The callback receives the entity and
376+ the already-rendered text of its children. Return a replacement string, or
377+ ` undefined ` to leave the span as-is.
378+
379+ ### ` .renderMap(text, fn) `
380+
381+ ``` ts
382+ renderMap <R >(text : string , fn : RenderMapFn < Entity , R > ): (string | R )[]
383+ ```
384+
385+ Like ` .render() ` , but instead of producing a single string, returns an array of
386+ segments: plain-text strings interleaved with values of type ` R ` produced by
387+ your callback. This is the API you want for React/JSX — map entities to
388+ elements, and the result is ready to drop into a component's children.
389+
390+ The callback receives ` { entity, children } ` where ` children ` is
391+ ` (string | R)[] ` — nested entities are already mapped.
392+
242393### ` .redact(text, opts?) `
243394
244395``` ts
245396redact (text : string , opts ?: RedactOptions ): string
246397```
247398
248- Extracts entities then replaces each matched character with ` opts.mask ` (default
249- ` "█" ` ). When ` opts.kinds ` is set, only those entity kinds are masked.
250- Overlapping spans are handled correctly .
399+ Built on top of ` .render() ` . Extracts entities then replaces each matched span
400+ with ` opts.mask ` (default ` "█" ` ). When ` opts.kinds ` is set, only those entity
401+ kinds are masked. Overlapping/nested spans are resolved via the span tree .
251402
252403### ` PIIParsers `
253404
@@ -275,6 +426,32 @@ interface RedactOptions<K extends string = string> {
275426}
276427```
277428
429+ ### ` RenderFn `
430+
431+ ``` ts
432+ type RenderFn <E > = (ctx : {
433+ entity: E ;
434+ children: string ;
435+ }) => string | undefined ;
436+ ```
437+
438+ Callback for ` .render() ` . Receives the entity and the already-rendered text of
439+ its nested children. Return a replacement string, or ` undefined ` to leave the
440+ span unchanged.
441+
442+ ### ` RenderMapFn `
443+
444+ ``` ts
445+ type RenderMapFn <E , R > = (ctx : {
446+ entity: E ;
447+ children: (string | R )[];
448+ }) => R ;
449+ ```
450+
451+ Callback for ` .renderMap() ` . Receives the entity and its children as an array of
452+ plain-text strings and already-mapped ` R ` values. Return a value of type ` R ` to
453+ replace the span.
454+
278455### ` AnyEntity `
279456
280457Union of all 15 built-in entity types. This is the return element type of
0 commit comments