fewrd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ <div align="center">
2
+
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset=".github/fewrd-logo.svg">
5
+ <img src=".github/fewrd-logo-neg.svg" alt="fewrd" width="380">
6
+ </picture>
7
+
8
+ **Finds what recurs in a string, cuts it loose, and gets you the gist in a
9
+ few words.**
10
+
11
+ ![dependencies: 0](https://img.shields.io/badge/dependencies-0-78C4B6?style=flat-square)
12
+ ![types: strict](https://img.shields.io/badge/types-strict-78C4B6?style=flat-square)
13
+ ![tests: 18 passing](https://img.shields.io/badge/tests-18%20passing-78C4B6?style=flat-square)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## Why
20
+
21
+ Your text is hiding treasure: protocol numbers under five different aliases,
22
+ dates wedged between dashes, amounts that only count with a `€` stapled on.
23
+ fewrd digs it all out in one pass, remembers exactly where each piece lives,
24
+ then folds away whatever a reader doesn't need — no regex spaghetti, no
25
+ stray commas left behind.
26
+
27
+ ```
28
+ normalise → anchors → expand → merge → segment ⇒ Cuts (depends on text + book only: cacheable)
29
+ render(Cuts, fold) ⇒ gist | tagged html (always dynamic)
30
+ ```
31
+
32
+ ## See it fold
33
+
34
+ A real case, unfolded:
35
+
36
+ > Pec - Prot. n. 0023993 del 23/09/2026 - Misura 1.7.2 della missione 1, componente 1 del PNRR "Rete dei servizi di facilitazione digitale" - Comune di Ghilarza - CUP F84D26000210006 - Trasmissione cronoprogramma procedurale
37
+
38
+ The same reading, with `protocol` and `cup` folded away:
39
+
40
+ > Misura 1.7.2 della missione 1, componente 1 del PNRR "Rete dei servizi di facilitazione digitale" - Comune di Ghilarza - Trasmissione cronoprogramma procedurale
41
+
42
+ No dangling dash where the protocol number used to sit, no orphaned separator
43
+ before "Trasmissione" — fewrd decides which separator survives a fold and
44
+ which bracket empties out along with what was inside it. Both strings come
45
+ from the same `Cuts`; nothing gets re-parsed to produce the second one.
46
+
47
+ ## The pieces
48
+
49
+ | name | what it is |
50
+ |---|---|
51
+ | `Recipe` | one pattern: a strict `anchor` (the value), closed lists of `left`/`right` neighbours (label, channel, date…), `requires`, `resolve`, `weak`, `rest` |
52
+ | `Book` | recipes in priority order, plus a `version` that keys a cached reading |
53
+ | `Mention` | one recognised thing: its `extent`, its `parts` in text order, a canonical `value`, and its `parent` when nested |
54
+ | `Leaf` | one piece of the partition: `text`, `sep`, `open`, `close`, or a mention's `part` |
55
+ | `Cuts` | the reading: `text`, `mentions`, `leaves`. The leaves cover the text in order, with no gap and no overlap. Survives a JSON round-trip |
56
+ | `Fold` | the caller's policy: which mentions a condensed view drops |
57
+
58
+ ## The rules
59
+
60
+ - **Anchors are strict and neighbours are generous.** A neighbour's regex is
61
+ pinned to the edge it grows from. Each neighbour attaches at most once, and
62
+ after each attachment the list is tried again from the top.
63
+ - **Nothing cuts a word.** An anchor or a neighbour may not start or end
64
+ inside a run of letters or digits (opt out with `glued`).
65
+ - **Longest extent wins, then priority.** `weak` recipes only fill gaps.
66
+ - **A `rest` recipe runs to the end of its level**, and what follows its
67
+ anchor is read again inside it. Folding it folds everything it holds.
68
+ - **Separator fate.** Separators between two surviving leaves stay untouched
69
+ when no fold fell among them. Otherwise only the strongest survives
70
+ (`-` > `;` > `:` > `,` > space), and none survives at an edge, after an
71
+ opening bracket or before closing punctuation. Brackets a fold empties go
72
+ with it.
73
+ - **One HTML, both views.** `html()` emits every leaf and marks what the
74
+ condensed view drops with `data-fold`. The whole switch is
75
+ `.condensed [data-fold] { display: none }`.
76
+
77
+ ## Use
78
+
79
+ ```ts
80
+ import { read, gist, html } from 'fewrd';
81
+ import { itPa } from 'fewrd/recipes/it-pa';
82
+
83
+ const cuts = read(subject, itPa);
84
+ const fold = (m) => ['protocol', 'cup'].includes(m.entity);
85
+ gist(cuts, fold); // plain text
86
+ html(cuts, fold); // tagged, both views
87
+ ```
88
+
89
+ ## Try it
90
+
91
+ ```bash
92
+ pnpm dev # playground on 5577 — every case in playground/cases.ts, folded and whole
93
+ ```
94
+
95
+ ## Recipes
96
+
97
+ `recipes/it-pa.ts` is the first book: Italian public administration codes
98
+ (protocol, CIG, CUP, chapter, amount, date, capitals tags, «con oggetto»).
99
+ `playground/cases.ts` holds the cases. The tests read those same cases.
100
+
101
+ ## Work
102
+
103
+ ```bash
104
+ pnpm test # node --test, type stripping, no build
105
+ pnpm typecheck
106
+ pnpm dev # playground on 5577
107
+ pnpm build # dist/ for package consumers (runs automatically on publish)
108
+ ```
@@ -0,0 +1,11 @@
1
+ import type { Book, Recipe } from '../src/index.ts';
2
+ export declare const protocol: Recipe;
3
+ export declare const cig: Recipe;
4
+ export declare const cup: Recipe;
5
+ export declare const chapter: Recipe;
6
+ export declare const amount: Recipe;
7
+ export declare const date: Recipe;
8
+ export declare const caps: Recipe;
9
+ /** From «con oggetto» to the end of its level, read again inside. */
10
+ export declare const quotation: Recipe;
11
+ export declare const itPa: Book;
@@ -0,0 +1,77 @@
1
+ // Italian public administration: the codes users stuff into a document's
2
+ // «oggetto». Labels vary wildly, values never do — so every anchor is strict
3
+ // and every label generous. Ported from sibardoc-prototype's subject fold.
4
+ const DATE = String.raw `\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}`;
5
+ function isoDate(d) {
6
+ const m = d.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/);
7
+ if (!m)
8
+ return null;
9
+ const [day, month] = [Number(m[1]), Number(m[2])];
10
+ if (day < 1 || day > 31 || month < 1 || month > 12)
11
+ return null;
12
+ return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
13
+ }
14
+ const mixed = (v) => /\p{L}/u.test(v) && /\d/.test(v);
15
+ /** Words that label another entity — never part of a capitals tag. */
16
+ const LABEL_WORDS = String.raw `(?:C\.?I\.?G|C\.?U\.?P|P\.?C\.?F|C\.?D\.?R|PROT|PEC|CAP|CAPITOL[OI]|COD|CODICE|FORN|FORNITORE|IVA|EURO|ID|RIF|DEL)\.?(?![\p{L}])`;
17
+ const CAPS_WORD = String.raw `(?!${LABEL_WORDS})\p{Lu}(?:[.'&]?\p{Lu}){2,}\.?`;
18
+ export const protocol = {
19
+ entity: 'protocol',
20
+ anchor: /\d{7}(?:\/\d{4})?/u,
21
+ left: [
22
+ { part: 'label', rx: /(?:Numero\s+Protocollo|Protocollo(?:\s+n[r°]?\.?)?|Prot\.?\s*(?:n[r°]?\.?)?|Rif\.?)\s*:?\s*/iu },
23
+ { part: 'channel', rx: /(?:PEC|Posta\s+certificata|Riscontro\s+a)\s*[:\-]\s*/iu },
24
+ ],
25
+ right: [{ part: 'date', rx: new RegExp(String.raw `\s*,?\s*del\s+${DATE}`, 'iu') }],
26
+ requires: ['label'],
27
+ resolve: (p) => p.value.slice(0, 7),
28
+ };
29
+ export const cig = {
30
+ entity: 'cig',
31
+ anchor: /[A-Z0-9]{10}/u,
32
+ left: [{ part: 'label', rx: /C\.?I\.?G\.?(?:\s+(?:derivato|originario|master))?(?:\s*n\.)?\s*[:\-]?\s*/iu }],
33
+ // Unlabelled, one letter and nine digits is a Co.Ge. account, not a CIG.
34
+ resolve: (p) => (mixed(p.value) && (p.label || !/^[A-Z]\d{9}$/.test(p.value)) ? p.value : null),
35
+ };
36
+ export const cup = {
37
+ entity: 'cup',
38
+ anchor: /[A-Z]\d{2}[A-Z0-9]{12}/u,
39
+ left: [{ part: 'label', rx: /C\.?\s?U\.?\s?P\.?(?:\s+(?:derivato|master))?(?:\s*n\.)?\s*[:\-]?\s*/iu }],
40
+ };
41
+ export const chapter = {
42
+ entity: 'chapter',
43
+ anchor: /[A-Z]{2}\d{2,3}\.\d{3,4}/u,
44
+ left: [{ part: 'label', rx: /(?:Capitol[oi]|Cap\.?)(?:\s+di\s+(?:spesa|entrata))?\s*:?\s*/iu }],
45
+ };
46
+ export const amount = {
47
+ entity: 'amount',
48
+ anchor: /\d+(?:\.\d{3})*(?:,\d{1,2})?/u,
49
+ left: [{ part: 'label', rx: /(?:€|euro)\s*/iu }],
50
+ right: [{ part: 'unit', rx: /\s*€/u }],
51
+ resolve: (p) => {
52
+ if (!p.label && !p.unit)
53
+ return null;
54
+ const [int, dec = ''] = p.value.split(',');
55
+ return `${int.replace(/\./g, '')}.${dec.padEnd(2, '0')}`;
56
+ },
57
+ };
58
+ export const date = {
59
+ entity: 'date',
60
+ anchor: new RegExp(DATE, 'u'),
61
+ resolve: (p) => isoDate(p.value),
62
+ };
63
+ export const caps = {
64
+ entity: 'caps',
65
+ anchor: new RegExp(String.raw `${CAPS_WORD}(?: ${CAPS_WORD})+`, 'u'),
66
+ weak: true,
67
+ };
68
+ /** From «con oggetto» to the end of its level, read again inside. */
69
+ export const quotation = {
70
+ entity: 'quotation',
71
+ anchor: /con oggetto\s*:?\s*/iu,
72
+ rest: true,
73
+ };
74
+ export const itPa = {
75
+ version: 'it-pa@1',
76
+ recipes: [protocol, cig, cup, chapter, amount, date, caps, quotation],
77
+ };
@@ -0,0 +1,3 @@
1
+ export type { Book, Cuts, Leaf, LeafKind, Mention, Neighbour, Recipe, Span } from './types.ts';
2
+ export { read } from './read.ts';
3
+ export { gist, html, shown, type Fold } from './render.ts';
@@ -0,0 +1,2 @@
1
+ export { read } from "./read.js";
2
+ export { gist, html, shown } from "./render.js";
@@ -0,0 +1,6 @@
1
+ export interface Normal {
2
+ text: string;
3
+ /** Original boundary of each copy boundary, `text.length + 1` entries. */
4
+ at: number[];
5
+ }
6
+ export declare function normalise(original: string): Normal;
@@ -0,0 +1,33 @@
1
+ // Matching runs on a normalised copy; nothing outside the engine sees it.
2
+ // NFKC, dash variants → `-`, curly quotes → straight, every whitespace run →
3
+ // one space. `at(i)` maps a copy boundary back to an original one.
4
+ const DASHES = /[‐-―−]/g;
5
+ const QUOTES = /[‘-‟]/g;
6
+ const straight = (q) => ('“”„‟'.includes(q) ? '"' : "'");
7
+ export function normalise(original) {
8
+ let text = '';
9
+ const at = [];
10
+ let inSpace = false;
11
+ let i = 0;
12
+ for (const cp of original) {
13
+ if (/\s/.test(cp)) {
14
+ if (!inSpace) {
15
+ at.push(i);
16
+ text += ' ';
17
+ }
18
+ inSpace = true;
19
+ }
20
+ else {
21
+ const piece = cp.normalize('NFKC').replace(DASHES, '-').replace(QUOTES, straight);
22
+ // An expanded code point maps every copy boundary inside it to its start.
23
+ for (const ch of piece) {
24
+ at.push(i);
25
+ text += ch;
26
+ }
27
+ inSpace = false;
28
+ }
29
+ i += cp.length;
30
+ }
31
+ at.push(original.length);
32
+ return { text, at };
33
+ }
@@ -0,0 +1,3 @@
1
+ import type { Book, Cuts } from './types.ts';
2
+ /** Read `text` with `book`: every mention, and the partition of the text into leaves. */
3
+ export declare function read(text: string, book: Book): Cuts;
@@ -0,0 +1,211 @@
1
+ // The reading: anchors → greedy expansion → merge → segmentation ⇒ Cuts.
2
+ //
3
+ // Everything here works in the normalised copy's coordinates and maps back to
4
+ // the original once, at the end. Nothing depends on anything but the text and
5
+ // the book, so a Cuts is cacheable by (text, book.version).
6
+ import { normalise } from "./normalise.js";
7
+ const WORD = /[\p{L}\p{N}]/u;
8
+ const isWord = (ch) => ch !== undefined && WORD.test(ch);
9
+ /** A match may not start inside a word: its first char is a word char and so is the one before. */
10
+ const cutsWordAtStart = (s, start) => isWord(s[start]) && isWord(s[start - 1]);
11
+ /** …nor end inside one. */
12
+ const cutsWordAtEnd = (s, end) => isWord(s[end - 1]) && isWord(s[end]);
13
+ const bare = (flags) => flags.replace(/[gy]/g, '');
14
+ const cache = new WeakMap();
15
+ function pinned(rx, side) {
16
+ let c = cache.get(rx);
17
+ if (!c)
18
+ cache.set(rx, (c = {}));
19
+ return (c[side] ??=
20
+ side === 'left'
21
+ ? new RegExp(`(?:${rx.source})$`, bare(rx.flags))
22
+ : side === 'right'
23
+ ? new RegExp(rx.source, `${bare(rx.flags)}y`)
24
+ : new RegExp(rx.source, `${bare(rx.flags).replace('d', '')}gd`));
25
+ }
26
+ /**
27
+ * Grow a candidate outward from its anchor, greedily, inside [from, to).
28
+ * ponytail: a `$`-pinned regex rescans the prefix, O(n²) worst case per
29
+ * neighbour; fine for subjects of a few thousand chars, reverse-match if not.
30
+ */
31
+ function expand(s, r, anchor, from, to) {
32
+ const parts = [{ part: 'value', span: anchor }];
33
+ const attached = new Set();
34
+ let cursor = anchor[0];
35
+ for (let grew = true; grew;) {
36
+ grew = false;
37
+ for (const nb of r.left ?? []) {
38
+ if (attached.has(nb.part))
39
+ continue;
40
+ const m = pinned(nb.rx, 'left').exec(s.slice(from, cursor));
41
+ if (!m || m[0].length === 0)
42
+ continue;
43
+ const start = from + m.index;
44
+ if (cutsWordAtStart(s, start))
45
+ continue;
46
+ parts.unshift({ part: nb.part, span: [start, cursor] });
47
+ attached.add(nb.part);
48
+ cursor = start;
49
+ grew = true;
50
+ break;
51
+ }
52
+ }
53
+ cursor = anchor[1];
54
+ const head = s.slice(0, to);
55
+ for (let grew = true; grew;) {
56
+ grew = false;
57
+ for (const nb of r.right ?? []) {
58
+ if (attached.has(nb.part))
59
+ continue;
60
+ const re = pinned(nb.rx, 'right');
61
+ re.lastIndex = cursor;
62
+ const m = re.exec(head);
63
+ if (!m || m[0].length === 0)
64
+ continue;
65
+ const end = cursor + m[0].length;
66
+ if (cutsWordAtEnd(s, end))
67
+ continue;
68
+ parts.push({ part: nb.part, span: [cursor, end] });
69
+ attached.add(nb.part);
70
+ cursor = end;
71
+ grew = true;
72
+ break;
73
+ }
74
+ }
75
+ return parts;
76
+ }
77
+ function candidates(s, recipes, from, to, weak) {
78
+ const out = [];
79
+ const head = s.slice(0, to);
80
+ recipes.forEach((r, index) => {
81
+ if (r.rest || !!r.weak !== weak)
82
+ return;
83
+ const re = pinned(r.anchor, 'scan');
84
+ re.lastIndex = from;
85
+ for (let m = re.exec(head); m; m = re.exec(head)) {
86
+ const anchor = [m.index, m.index + m[0].length];
87
+ if (anchor[0] === anchor[1]) {
88
+ re.lastIndex = m.index + 1;
89
+ continue;
90
+ }
91
+ if (!r.glued && (cutsWordAtStart(s, anchor[0]) || cutsWordAtEnd(s, anchor[1]))) {
92
+ re.lastIndex = m.index + 1; // a rejected match must not hide a later one
93
+ continue;
94
+ }
95
+ const parts = expand(s, r, anchor, from, to);
96
+ const text = Object.fromEntries(parts.map((p) => [p.part, s.slice(p.span[0], p.span[1])]));
97
+ const value = (r.requires ?? []).every((p) => p in text) ? (r.resolve ? r.resolve(text) : text.value) : null;
98
+ if (value === null) {
99
+ re.lastIndex = m.index + 1;
100
+ continue;
101
+ }
102
+ out.push({
103
+ recipe: index,
104
+ entity: r.entity,
105
+ extent: [parts[0].span[0], parts.at(-1).span[1]],
106
+ parts,
107
+ value,
108
+ });
109
+ }
110
+ });
111
+ return out;
112
+ }
113
+ const overlaps = (a, b) => a[0] < b[1] && b[0] < a[1];
114
+ const size = (c) => c.extent[1] - c.extent[0];
115
+ /** Longest extent first, then priority; a loser never overlaps a winner. */
116
+ function merge(pool, taken) {
117
+ const kept = [...taken];
118
+ for (const c of [...pool].sort((a, b) => size(b) - size(a) || a.recipe - b.recipe))
119
+ if (!kept.some((k) => overlaps(k.extent, c.extent)))
120
+ kept.push(c);
121
+ return kept.sort((a, b) => a.extent[0] - b.extent[0]);
122
+ }
123
+ /**
124
+ * The separator grammar: whitespace, `,;:` followed by space or end, and a
125
+ * dash standing free. A hyphen inside a word is text. Brackets are their own
126
+ * leaves so a fold can empty them.
127
+ */
128
+ const PIECE = /(?<sep>(?:\s|[,;:](?=\s|$)|(?<=^|\s)-(?=\s|$))+)|(?<open>[(\[])|(?<close>[)\]])/gu;
129
+ function segment(s, from, to, mention, out) {
130
+ const push = (start, end, kind) => {
131
+ if (end > start)
132
+ out.push({ start, end, kind, ...(mention !== undefined ? { mention } : {}) });
133
+ };
134
+ PIECE.lastIndex = from;
135
+ let at = from;
136
+ for (let m = PIECE.exec(s); m && m.index < to; m = PIECE.exec(s)) {
137
+ push(at, m.index, 'text');
138
+ const end = Math.min(m.index + m[0].length, to);
139
+ push(m.index, end, m.groups.sep ? 'sep' : m.groups.open ? 'open' : 'close');
140
+ at = end;
141
+ }
142
+ push(at, to, 'text');
143
+ }
144
+ function level(s, recipes, from, to, parent, mentions, leaves) {
145
+ // The earliest `rest` anchor closes this level's head.
146
+ let rest;
147
+ recipes.forEach((r, index) => {
148
+ if (!r.rest)
149
+ return;
150
+ const re = pinned(r.anchor, 'scan');
151
+ re.lastIndex = from;
152
+ for (let m = re.exec(s); m && m.index < to; m = re.exec(s)) {
153
+ const at = [m.index, Math.min(m.index + m[0].length, to)];
154
+ if (!r.glued && (cutsWordAtStart(s, at[0]) || cutsWordAtEnd(s, at[1])))
155
+ continue;
156
+ if (!rest || at[0] < rest.at[0])
157
+ rest = { recipe: index, at };
158
+ break;
159
+ }
160
+ });
161
+ const limit = rest ? rest.at[0] : to;
162
+ const kept = merge(candidates(s, recipes, from, limit, true), merge(candidates(s, recipes, from, limit, false), []));
163
+ let at = from;
164
+ for (const c of kept) {
165
+ segment(s, at, c.extent[0], parent, leaves);
166
+ const index = mentions.push({ entity: c.entity, recipe: c.recipe, extent: c.extent, parts: c.parts, value: c.value, ...(parent !== undefined ? { parent } : {}) }) - 1;
167
+ for (const p of c.parts)
168
+ leaves.push({ start: p.span[0], end: p.span[1], kind: 'part', part: p.part, mention: index });
169
+ at = c.extent[1];
170
+ }
171
+ segment(s, at, limit, parent, leaves);
172
+ if (rest) {
173
+ const index = mentions.push({
174
+ entity: recipes[rest.recipe].entity,
175
+ recipe: rest.recipe,
176
+ extent: [rest.at[0], to],
177
+ parts: [{ part: 'lead', span: rest.at }],
178
+ value: s.slice(rest.at[1], to),
179
+ ...(parent !== undefined ? { parent } : {}),
180
+ }) - 1;
181
+ leaves.push({ start: rest.at[0], end: rest.at[1], kind: 'part', part: 'lead', mention: index });
182
+ level(s, recipes, rest.at[1], to, index, mentions, leaves);
183
+ }
184
+ }
185
+ /** Read `text` with `book`: every mention, and the partition of the text into leaves. */
186
+ export function read(text, book) {
187
+ const n = normalise(text);
188
+ const raw = [];
189
+ const rawLeaves = [];
190
+ level(n.text, book.recipes, 0, n.text.length, undefined, raw, rawLeaves);
191
+ const span = ([s, e]) => ({ start: n.at[s], end: n.at[e] });
192
+ const mentions = raw.map((m) => ({
193
+ ...m,
194
+ extent: span(m.extent),
195
+ parts: m.parts.map((p) => ({ part: p.part, span: span(p.span) })),
196
+ }));
197
+ const leaves = rawLeaves
198
+ .map((l) => ({ ...l, start: n.at[l.start], end: n.at[l.end] }))
199
+ .filter((l) => l.end > l.start);
200
+ // A value comes from the copy; hand back the original's own characters.
201
+ for (const m of mentions) {
202
+ const r = book.recipes[m.recipe];
203
+ if (r.rest)
204
+ m.value = text.slice(m.parts[0].span.end, m.extent.end);
205
+ else if (!r.resolve) {
206
+ const v = m.parts.find((p) => p.part === 'value').span;
207
+ m.value = text.slice(v.start, v.end);
208
+ }
209
+ }
210
+ return { text, book: book.version, mentions, leaves };
211
+ }
@@ -0,0 +1,21 @@
1
+ import type { Cuts, Mention } from './types.ts';
2
+ export type Fold = (mention: Mention, index: number) => boolean;
3
+ /**
4
+ * Which leaves the condensed view shows. With nothing folded, every leaf.
5
+ *
6
+ * - A mention folds when the policy says so, or when the mention holding it folds.
7
+ * - A bracket pair folds when nothing between them survives.
8
+ * - The separators between two surviving leaves survive untouched when no
9
+ * fold fell among them; otherwise only the strongest one survives, and none
10
+ * at an edge, after an opening bracket or before closing punctuation.
11
+ */
12
+ export declare function shown(cuts: Cuts, fold: Fold): boolean[];
13
+ /** The condensed view as plain text. */
14
+ export declare function gist(cuts: Cuts, fold: Fold): string;
15
+ /**
16
+ * Tagged HTML holding both views: every leaf is there, and what the condensed
17
+ * view drops carries `data-fold` — so `.condensed [data-fold] { display: none }`
18
+ * is the whole switch. Mentions are `<span data-entity data-mention>`, parts
19
+ * `<span data-part>`, separators `<span data-sep>`, nested as the mentions nest.
20
+ */
21
+ export declare function html(cuts: Cuts, fold: Fold): string;
@@ -0,0 +1,101 @@
1
+ // The last pass, always dynamic: a fold policy decides which mentions go, the
2
+ // separator fate decides which separators go with them, and the same leaves
3
+ // render as a gist or as tagged HTML carrying both views at once.
4
+ /** A separator's weight when a fold makes several meet: the strongest stays. */
5
+ const strength = (s) => (s.includes('-') ? 4 : s.includes(';') ? 3 : s.includes(':') ? 2 : s.includes(',') ? 1 : 0);
6
+ const CLOSING = /^[.,;:!?)\]]/;
7
+ /**
8
+ * Which leaves the condensed view shows. With nothing folded, every leaf.
9
+ *
10
+ * - A mention folds when the policy says so, or when the mention holding it folds.
11
+ * - A bracket pair folds when nothing between them survives.
12
+ * - The separators between two surviving leaves survive untouched when no
13
+ * fold fell among them; otherwise only the strongest one survives, and none
14
+ * at an edge, after an opening bracket or before closing punctuation.
15
+ */
16
+ export function shown(cuts, fold) {
17
+ return fate(cuts, fold).keep;
18
+ }
19
+ function fate(cuts, fold) {
20
+ const { text, mentions, leaves } = cuts;
21
+ const folded = mentions.map(() => false);
22
+ mentions.forEach((m, i) => (folded[i] = fold(m, i) || (m.parent !== undefined && folded[m.parent])));
23
+ const keep = leaves.map((l) => l.kind !== 'sep' && !(l.mention !== undefined && folded[l.mention]));
24
+ const content = (l) => l.kind === 'text' || l.kind === 'part';
25
+ // Brackets: pair by stack; a pair with no surviving content inside folds.
26
+ const stack = [];
27
+ leaves.forEach((l, i) => {
28
+ if (l.kind === 'open')
29
+ stack.push(i);
30
+ else if (l.kind === 'close' && stack.length) {
31
+ const o = stack.pop();
32
+ if (!leaves.slice(o + 1, i).some((x, k) => content(x) && keep[o + 1 + k]))
33
+ keep[o] = keep[i] = false;
34
+ }
35
+ });
36
+ // Separators: each run between two surviving non-separator leaves.
37
+ let prev = -1;
38
+ for (let i = 0; i <= leaves.length; i++) {
39
+ if (i < leaves.length && (leaves[i].kind === 'sep' || !keep[i]))
40
+ continue;
41
+ const between = [...Array(i - prev - 1).keys()].map((k) => prev + 1 + k);
42
+ const seps = between.filter((k) => leaves[k].kind === 'sep');
43
+ const cut = between.length !== seps.length;
44
+ if (!cut)
45
+ seps.forEach((k) => (keep[k] = true));
46
+ else {
47
+ const slice = (l) => text.slice(l.start, l.end);
48
+ const edge = prev < 0 || i === leaves.length;
49
+ const hugs = (prev >= 0 && leaves[prev].kind === 'open') || (i < leaves.length && (leaves[i].kind === 'close' || CLOSING.test(slice(leaves[i]))));
50
+ if (!edge && !hugs && seps.length) {
51
+ const best = seps.reduce((a, b) => (strength(slice(leaves[b])) > strength(slice(leaves[a])) ? b : a));
52
+ keep[best] = true;
53
+ }
54
+ }
55
+ prev = i;
56
+ }
57
+ return { keep, folded };
58
+ }
59
+ /** The condensed view as plain text. */
60
+ export function gist(cuts, fold) {
61
+ const keep = shown(cuts, fold);
62
+ return cuts.leaves.map((l, i) => (keep[i] ? cuts.text.slice(l.start, l.end) : '')).join('');
63
+ }
64
+ const escape = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
65
+ /**
66
+ * Tagged HTML holding both views: every leaf is there, and what the condensed
67
+ * view drops carries `data-fold` — so `.condensed [data-fold] { display: none }`
68
+ * is the whole switch. Mentions are `<span data-entity data-mention>`, parts
69
+ * `<span data-part>`, separators `<span data-sep>`, nested as the mentions nest.
70
+ */
71
+ export function html(cuts, fold) {
72
+ const { keep, folded } = fate(cuts, fold);
73
+ const { mentions } = cuts;
74
+ const chain = (m) => (m === undefined ? [] : [...chain(mentions[m].parent), m]);
75
+ // Marked only where the hiding starts; whatever sits inside inherits it.
76
+ const marked = (m) => folded[m] && !(mentions[m].parent !== undefined && folded[mentions[m].parent]);
77
+ const tag = (on) => (on ? ' data-fold' : '');
78
+ let out = '';
79
+ const open = [];
80
+ cuts.leaves.forEach((l, i) => {
81
+ const want = chain(l.mention);
82
+ let common = 0;
83
+ while (common < open.length && open[common] === want[common])
84
+ common++;
85
+ for (; open.length > common; open.pop())
86
+ out += '</span>';
87
+ for (const m of want.slice(common)) {
88
+ out += `<span data-entity="${escape(mentions[m].entity)}" data-mention="${m}"${tag(marked(m))}>`;
89
+ open.push(m);
90
+ }
91
+ const t = escape(cuts.text.slice(l.start, l.end));
92
+ const hide = !keep[i] && !(l.mention !== undefined && folded[l.mention]);
93
+ if (l.kind === 'part')
94
+ out += `<span data-part="${escape(l.part)}">${t}</span>`;
95
+ else if (l.kind === 'text' && !hide)
96
+ out += t;
97
+ else
98
+ out += `<span data-${l.kind}${tag(hide)}>${t}</span>`;
99
+ });
100
+ return out + '</span>'.repeat(open.length);
101
+ }
@@ -0,0 +1,81 @@
1
+ /** Half-open, indices into the ORIGINAL string. */
2
+ export interface Span {
3
+ start: number;
4
+ end: number;
5
+ }
6
+ /**
7
+ * A neighbour a recipe may attach beside its anchor. Write `rx` plainly: the
8
+ * engine pins it to the anchor's edge (`$` on the left, sticky on the right).
9
+ */
10
+ export interface Neighbour {
11
+ part: string;
12
+ rx: RegExp;
13
+ }
14
+ /**
15
+ * One pattern: a strict anchor, then a closed list of neighbours tried outward.
16
+ * Each neighbour attaches at most once; after every attachment the list is
17
+ * tried again from the top, so declaration order is priority at each step.
18
+ */
19
+ export interface Recipe {
20
+ entity: string;
21
+ /** The value, strict. Its part is `value` (`lead` on a `rest` recipe). */
22
+ anchor: RegExp;
23
+ left?: readonly Neighbour[];
24
+ right?: readonly Neighbour[];
25
+ /** Parts that must attach, or the candidate is dropped. */
26
+ requires?: readonly string[];
27
+ /** Canonical value from the attached parts' text, or null: not this entity after all. */
28
+ resolve?: (parts: Readonly<Record<string, string>>) => string | null;
29
+ /** Fills only the gaps the strong recipes leave. */
30
+ weak?: boolean;
31
+ /**
32
+ * The mention runs from its anchor to the end of its level, and what follows
33
+ * the anchor is read again inside it. Neighbours are ignored.
34
+ */
35
+ rest?: boolean;
36
+ /** The anchor may start or end inside a word. Default: it may not. */
37
+ glued?: boolean;
38
+ }
39
+ /** Recipes in priority order, and the version that keys a cached reading. */
40
+ export interface Book {
41
+ version: string;
42
+ recipes: readonly Recipe[];
43
+ }
44
+ export interface Mention {
45
+ entity: string;
46
+ /** Index into the book's recipes. */
47
+ recipe: number;
48
+ /** Every part, contiguous: what folding removes. */
49
+ extent: Span;
50
+ /** In text order. */
51
+ parts: {
52
+ part: string;
53
+ span: Span;
54
+ }[];
55
+ /** `resolve`'s answer, or the anchor's text. */
56
+ value: string;
57
+ /** The `rest` mention this one sits inside. */
58
+ parent?: number;
59
+ }
60
+ export type LeafKind = 'text' | 'sep' | 'open' | 'close' | 'part';
61
+ /**
62
+ * One piece of the partition. `mention` is the innermost mention holding it
63
+ * (a `text` or `sep` leaf may sit inside a `rest` mention).
64
+ */
65
+ export interface Leaf {
66
+ start: number;
67
+ end: number;
68
+ kind: LeafKind;
69
+ part?: string;
70
+ mention?: number;
71
+ }
72
+ /**
73
+ * The cuts index: leaves partition `text` in order, no gap, no overlap —
74
+ * concatenating their slices gives `text` back, character for character.
75
+ */
76
+ export interface Cuts {
77
+ text: string;
78
+ book: string;
79
+ mentions: Mention[];
80
+ leaves: Leaf[];
81
+ }
@@ -0,0 +1,3 @@
1
+ // The vocabulary. A reading is plain data: no RegExp, no functions, nothing a
2
+ // JSON round-trip loses — so it can be cached by (text, book version).
3
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "fewrd",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Read a string with recipes: anchors, greedy expansion, a cuts index, and a fold-aware render.",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/src/index.d.ts",
12
+ "default": "./dist/src/index.js"
13
+ },
14
+ "./recipes/*": {
15
+ "types": "./dist/recipes/*.d.ts",
16
+ "default": "./dist/recipes/*.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "test": "node --test \"test/*.test.ts\"",
21
+ "typecheck": "tsc",
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "prepublishOnly": "npm run build",
24
+ "dev": "vite playground --port 5577 --strictPort"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.6.3",
28
+ "typescript": "^5.9.3",
29
+ "vite": "^7.3.6"
30
+ }
31
+ }