Skip to content

Commit b9c304f

Browse files
committed
feat: add render method
1 parent f408916 commit b9c304f

4 files changed

Lines changed: 849 additions & 48 deletions

File tree

README.md

Lines changed: 213 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
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">
@@ -23,16 +23,19 @@
2323
```ts
2424
import { Duckling, PIIParsers } from "@claudiu-ceia/ts-duckling";
2525

26-
// Extract structured entities
26+
// Extract structured entities from a chat message
2727
const 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
100111
import { 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

114125
Each 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
130141
type**:
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:
146157
import { 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
153166
Duckling(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

163284
Define a parser that returns an `Entity`, then pass it to `Duckling`:
@@ -192,7 +313,7 @@ Custom parsers compose freely with the built-in ones:
192313
import { Email } from "@claudiu-ceia/ts-duckling";
193314

194315
const 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
230356
parsers and returns `AnyEntity[]`. When given an explicit parser array, the
231357
return type narrows to the union of those entity types.
232358

@@ -239,15 +365,40 @@ extract(text: string): Entity[]
239365
Scans `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
245396
redact(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

280457
Union of all 15 built-in entity types. This is the return element type of

0 commit comments

Comments
 (0)