fewrd 0.3.0 → 1.0.1

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 CHANGED
@@ -5,278 +5,570 @@
5
5
  <img src=".github/fewrd-logo-neg.svg" alt="fewrd" width="380">
6
6
  </picture>
7
7
 
8
- **Finds what recurs in a string, cuts it loose, and gets you the gist in a
9
- few words.**
8
+ **Finds what recurs in a string, and lets a reader fold it away.**
10
9
 
11
10
  [![npm](https://img.shields.io/npm/v/fewrd?style=flat-square&color=78C4B6)](https://www.npmjs.com/package/fewrd)
12
11
  ![dependencies: 0](https://img.shields.io/badge/dependencies-0-78C4B6?style=flat-square)
13
- ![core: 4 kB gzip](https://img.shields.io/badge/core-4%20kB%20gzip-78C4B6?style=flat-square)
12
+ ![core: 6.5 kB gzip](https://img.shields.io/badge/core-6.5%20kB%20gzip-78C4B6?style=flat-square)
14
13
  ![types: strict](https://img.shields.io/badge/types-strict-78C4B6?style=flat-square)
15
- ![tests: 59 passing](https://img.shields.io/badge/tests-59%20passing-78C4B6?style=flat-square)
14
+ ![tests: 258 passing](https://img.shields.io/badge/tests-258%20passing-78C4B6?style=flat-square)
16
15
  [![license: MIT](https://img.shields.io/badge/license-MIT-78C4B6?style=flat-square)](LICENSE)
17
16
 
18
17
  </div>
19
18
 
20
19
  ---
21
20
 
22
- ## Why
21
+ A subject line from an Italian public administration, as filed:
23
22
 
24
- Your text is hiding treasure: protocol numbers under five different aliases,
25
- dates wedged between dashes, amounts that only count with a `€` stapled on.
26
- fewrd digs it all out in one pass, remembers exactly where each piece lives,
27
- then folds away whatever a reader doesn't need — no regex spaghetti, no
28
- stray commas left behind.
23
+ > PEC: Prot. n. 0018842 del 12/09/2026 - Richiesta informazioni sullo stato della pratica di rimborso - Comune di Bosa
24
+
25
+ The same line, with the protocol reference folded away:
26
+
27
+ > Richiesta informazioni sullo stato della pratica di rimborso - Comune di Bosa
28
+
29
+ Nothing was thrown away. The protocol is still there as `0018842` and the date as `2026-09-12`, the word `del` that joined the date on went with it, and no dash was left dangling. An inbox line works the same way, with the `Re:`/`Fwd:` chain and the ticket key folded:
30
+
31
+ > Re: Fwd: Invoice INV-2026-0042 for $1,250.00 due 2026-10-15
32
+ >
33
+ > Invoice for $1,250.00 due 2026-10-15
34
+
35
+ while the amount reads as `1250.00 USD` and the due date as `2026-10-15`.
36
+
37
+ You describe what recurs in a **conf**, a JSON file of named patterns that a domain expert can read and edit. fewrd does the rest in two halves:
29
38
 
30
39
  ```
31
- normalise → anchors → expand → merge → segment ⇒ Cuts (depends on text + book only: cacheable)
32
- render(Cuts, fold) ⇒ gist | tagged html (always dynamic)
40
+ text + conf → find → chart every row every pattern can see, nothing chosen (cacheable)
41
+ chart → dom → tree one reading: nodes, values, roles, water
42
+ tree + fold → gist → text what the reader keeps, punctuation tidied
33
43
  ```
34
44
 
45
+ Rendering the tree is yours. fewrd stops at data.
46
+
35
47
  ## Install
36
48
 
37
49
  ```bash
38
50
  npm install fewrd
39
51
  ```
40
52
 
41
- ```bash
42
- pnpm add fewrd
43
- ```
44
-
45
- ESM only, zero dependencies, types included. Runs in Node 18+ and any current
46
- browser or bundler.
53
+ ESM only, zero dependencies, types included. Runs in current Node and in any current browser or bundler. Coming from 0.3.0, the book-and-recipes API: the [changelog](CHANGELOG.md) says what changed and how to upgrade.
47
54
 
48
55
  ## Quick start
49
56
 
50
- Describe what recurs as a **book** of recipes, in JSON:
57
+ A conf names **tags**, the kinds of thing to find. A **root tag** is found by a regular expression; a **composed tag** is built by a **search** that starts from the finds of another tag and grows outward. This one finds a product code, and a composed `sku` that is the code with the word before it:
51
58
 
52
59
  ```json
53
60
  {
54
- "$schema": "./node_modules/fewrd/book.schema.json",
55
61
  "version": "shop@1",
56
- "defs": { "SKU": "[A-Z]{3}-\\d{4}" },
57
- "recipes": [
58
- {
59
- "entity": "sku",
60
- "anchor": "/%{SKU}/iu",
61
- "left": [{ "part": "label", "rx": "/(?:sku|code)\\s*:?\\s*/iu" }],
62
- "resolve": "upper"
63
- }
64
- ]
62
+ "patterns": {
63
+ "SKU": "[A-Z]{3}-\\d{4}",
64
+ "SEP": "(?:\\s|[,;:](?=\\s|$)|(?<=^|\\s)-(?=\\s|$))+"
65
+ },
66
+ "tags": {
67
+ "sku": {
68
+ "resolve": "upper",
69
+ "search": [
70
+ { "from": "sku-code", "back": [{ "tag": "sep", "optional": true }, { "tag": "sku-word", "as": "label" }] }
71
+ ]
72
+ },
73
+ "sku-code": { "rx": "/%{SKU}/iu" },
74
+ "sku-word": { "rx": "/sku|code/iu" },
75
+ "sep": { "rx": "/%{SEP}/u", "fate": "separator" }
76
+ }
65
77
  }
66
78
  ```
67
79
 
68
- Compile it once, then read any text with it:
80
+ Compile it once, with the functions it names, then find in any text:
69
81
 
70
82
  ```ts
71
- import { compile, gist, html, read } from 'fewrd';
83
+ import { compile, find, dom, gist } from 'fewrd';
72
84
  import data from './shop.json' with { type: 'json' };
73
85
 
74
- const { book, errors } = compile(data, { resolvers: { upper: (p) => p.value.toUpperCase() } });
86
+ const { conf, errors } = compile(data, { resolvers: { upper: (p) => p.value.toUpperCase() } });
87
+ console.log(errors);
75
88
 
76
- const cuts = read('Refund approved - SKU: abc-1234 - customer notified', book);
77
- cuts.mentions[0].value; // 'ABC-1234'
78
- gist(cuts, (m) => m.entity === 'sku'); // 'Refund approved - customer notified'
79
- html(cuts, (m) => m.entity === 'sku'); // both views in one markup, see Rendering
89
+ const text = 'Refund approved - SKU: abc-1234 - customer notified';
90
+ const chart = find(text, conf);
91
+ console.log(chart.spans('sku'));
92
+ console.log(JSON.stringify(chart));
80
93
  ```
81
94
 
82
- ## See it fold
95
+ ```
96
+ []
97
+ [ [ 18, 31 ] ]
98
+ {"$":[[51,51]],"^":[[0,0]],"sep":[[6,7],[15,18],[16,18],[17,18],[21,23],[22,23],[31,34],[32,34],[33,34],[42,43]],"sku":[[18,31]],"sku-code":[[23,31]],"sku-word":[[18,21]]}
99
+ ```
83
100
 
84
- A real Italian public-administration subject, unfolded:
101
+ The chart is a map from each tag to its **rows**, each row a span `[start, end]` into your string: `text.slice(18, 31)` is `SKU: abc-1234`. It holds every row, including ones that overlap: the three `sep` rows ending at 18 are ` - `, `- ` and ` `. Now choose one reading, and fold it:
85
102
 
86
- > 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
103
+ ```ts
104
+ const doc = dom(text, chart, conf);
105
+ const sku = doc.children.find((n) => n.tag === 'sku')!;
106
+ console.log(sku.value);
107
+ console.log(sku.attrs.label);
87
108
 
88
- The same reading, with `protocol` and `cup` folded away:
109
+ console.log(gist(doc, (n) => n.tag === 'sku'));
110
+ console.log(gist(doc, () => false));
111
+ ```
89
112
 
90
- > Misura 1.7.2 della missione 1, componente 1 del PNRR "Rete dei servizi di facilitazione digitale" - Comune di Ghilarza - Trasmissione cronoprogramma procedurale
113
+ ```
114
+ SKU: ABC-1234
115
+ { tag: 'sku-word', start: 18, end: 21, attrs: {}, children: [] }
116
+ Refund approved - customer notified
117
+ Refund approved - SKU: abc-1234 - customer notified
118
+ ```
91
119
 
92
- No dangling dash where the protocol number used to sit, no orphaned separator
93
- before "Trasmissione" — fewrd decides which separator survives a fold and
94
- which bracket empties out along with what was inside it. Both strings come
95
- from the same `Cuts`; nothing gets re-parsed to produce the second one.
120
+ `value` is what the `upper` resolver made of the row, and `attrs.label` is the node the search took for the atom it called `label`. Folding `sku` dropped the labelled code and one of the two dashes that were left side by side; folding nothing gives the text back.
96
121
 
97
- Or an inbox line, with the `Re:`/`Fwd:` chain and the ticket key folded:
122
+ ## How it thinks
98
123
 
99
- > Re: Fwd: Invoice INV-2026-0042 for $1,250.00 due 2026-10-15
100
- >
101
- > Invoice for $1,250.00 due 2026-10-15
124
+ **The chart chooses nothing.** `find` keeps every row every pattern can produce, overlapping ones included, and it depends on the text and the conf alone. So a chart can be cached by text and conf version, and any number of readings can be built from one.
125
+
126
+ **The tree is one reading.** `dom` walks the chart's rows in a fixed order, keeps those that do not cross, re-runs each composed row's search to bind its roles, resolves values bottom-up, and fills the gaps with water. No two nodes cross; the leaves cover the text exactly once.
127
+
128
+ **The fold is a policy plus three fates.** A fold is a function from a node to a boolean. What it hides, the fates tidy up: a connector goes with what it introduced, a bracket empties out, and of the separators left side by side only the strongest survives.
102
129
 
103
- …while the money still reads as `1250.00 USD` and the date as `2026-10-15`.
130
+ The parts and the boundaries are drawn in [docs/architecture.md](docs/architecture.md); what fewrd is for, and what it deliberately is not, is in [docs/vision.md](docs/vision.md); every christened name is in the [glossary](docs/glossary.md).
104
131
 
105
- ## The pieces
132
+ ## The conf
106
133
 
107
- | name | what it is |
134
+ A conf is JSON, so it diffs, reviews and validates like any config. Its top level has three keys:
135
+
136
+ | key | holds |
108
137
  |---|---|
109
- | `BookData` | a book as plain JSON: patterns as `"/source/flags"`, shared `defs` spliced in as `%{NAME}`, `resolve` by name |
110
- | `compile` | `BookData` + your named resolvers → a `Book`, plus a list of errors. Never throws |
111
- | `Recipe` | one pattern: a strict `anchor` (the value), closed lists of `left`/`right` neighbours (label, channel, date…), `requires`, `resolve`, `weak`, `rest`, `glued` |
112
- | `Book` | recipes in priority order, plus a `version` that keys a cached reading |
113
- | `Mention` | one recognised thing: its `extent`, its `parts` in text order, a canonical `value`, and its `parent` when nested |
114
- | `Leaf` | one piece of the partition: `text`, `sep`, `open`, `close`, or a mention's `part` |
115
- | `Cuts` | the reading: `text`, `mentions`, `leaves`. The leaves cover the text in order, with no gap and no overlap. Survives a JSON round-trip |
116
- | `Fold` | your policy: which mentions a condensed view drops |
117
-
118
- ## The rules
119
-
120
- - **Anchors are strict and neighbours are generous.** A neighbour's regex is
121
- pinned to the edge it grows from. Each neighbour attaches at most once, and
122
- after each attachment the list is tried again from the top.
123
- - **Nothing cuts a word.** An anchor or a neighbour may not start or end
124
- inside a run of letters or digits (opt out with `glued`).
125
- - **Longest extent wins, then priority.** `weak` recipes only fill gaps.
126
- - **A `rest` recipe runs to the end of its level**, and what follows its
127
- anchor is read again inside it. Folding it folds everything it holds.
128
- - **Separator fate.** Separators between two surviving leaves stay untouched
129
- when no fold fell among them. Otherwise only the strongest survives
130
- (`-` > `;` > `:` > `,` > space), and none survives at an edge, after an
131
- opening bracket or before closing punctuation. Brackets a fold empties go
132
- with it.
133
- - **One HTML, both views.** `html()` emits every leaf and marks what the
134
- condensed view drops with `data-fold`. The whole switch is
135
- `.condensed [data-fold] { display: none }`.
136
-
137
- ## Books as data
138
-
139
- A book is JSON, so it diffs, reviews and validates like any config. Point its
140
- `$schema` at `./node_modules/fewrd/book.schema.json` (relative to the file)
141
- and your editor flags typos, wrong types and missing fields as you type.
142
-
143
- | key | what it holds |
138
+ | `version` | Required. Keys a cached chart: change it whenever a change to the conf can change what `find` returns. |
139
+ | `patterns` | Named regular-expression sources, spliced into other patterns as `%{NAME}`. |
140
+ | `tags` | Required. A map from tag name to tag. Its key order is a priority, read in one place only (below). |
141
+
142
+ **Patterns** are regex literals in a string, `"/source/flags"`, and JSON doubles the backslashes. `%{NAME}` splices in a named pattern as one group, so an `a|b` inside never leaks out, and named patterns may use each other. `%\{` is a literal `%{`. A named pattern inside a `[…]` character class is not supported.
143
+
144
+ **A tag** has exactly one of `rx` and `search`. With `rx` it is a root tag, found by that pattern. With `search`, a list of searches, it is composed, as `protocol` is in the Italian conf (`confs/it-pa.json`):
145
+
146
+ ```json
147
+ "serial": { "rx": "/\\d{7}(?:\\/\\d{4})?/u" },
148
+ "prot-word": { "rx": "/numero\\s+protocollo|protocollo|prot\\.?|rif\\.?/iu" },
149
+ "protocol": { "resolve": "protocol", "search": [
150
+ { "from": "serial", "back": [
151
+ { "tag": "sep", "optional": true }, { "tag": "num-word", "optional": true }, { "tag": "sep", "optional": true },
152
+ { "tag": "prot-word", "as": "label" }
153
+ ] },
154
+ { "from": "protocol", "back": [ { "tag": "sep", "optional": true }, { "tag": "channel", "as": "channel" } ] }
155
+ ] }
156
+ ```
157
+
158
+ **A search** starts from each row of its `from` tag and walks outward through a list of **atoms**, either `back` (leftward from the row's start, nearest atom first) or `forward` (rightward from its end), exactly one of the two. The second search above grows a `protocol` from a `protocol`: that is how a channel in front of an already labelled number joins it, and how repetition is written in general.
159
+
160
+ **An atom** is one of:
161
+
162
+ | atom | takes |
144
163
  |---|---|
145
- | `version` | required; change it whenever a recipe change can change output |
146
- | `defs` | named pattern fragments, source only: `{ "DATE": "\\d{4}-\\d{2}-\\d{2}" }` |
147
- | `recipes` | required; in priority order |
148
- | `recipes[].entity` | required; what the recipe recognises |
149
- | `recipes[].anchor` | required; a pattern, the value itself |
150
- | `recipes[].left` / `right` | neighbours: `{ "part": "label", "rx": "/…/iu" }` |
151
- | `recipes[].requires` | parts that must attach, e.g. `["currency"]` |
152
- | `recipes[].resolve` | the name of a resolver you pass to `compile` |
153
- | `recipes[].weak` / `rest` / `glued` | flags, see The rules |
154
-
155
- **Patterns** are regex literals in a string: `"/source/flags"` — JSON doubles
156
- the backslashes. `%{NAME}` splices in a fragment from `defs` as one unit (an
157
- `a|b` inside never leaks out) and fragments may use other fragments. `%\{` is
158
- a literal `%{`. Fragments inside a `[…]` character class aren't supported.
159
-
160
- **Resolvers** are the only code in a book: functions from the attached parts'
161
- text to a canonical value, or `null` for "not this entity after all". Nothing
162
- in the JSON is ever evaluated.
164
+ | `{ "tag": "sep" }` | a row of that tag |
165
+ | `{ "tag": ["w", "n"] }` | a row of any tag in the list |
166
+ | `{ "rx": "/…/u" }` | a stretch of the text itself |
167
+
168
+ Either kind may carry `"as": "name"`, which binds what the atom took to a **role** of that name, and `"optional": true`, which lets the search try the sequence with and without the atom. The outermost atom (the last in the list) may not be optional, and two regex atoms may not meet (merge them into one pattern).
169
+
170
+ **Reserved names.** `^` is the start of the text, a row at `(0,0)`; `$` is its end, a row at `(n,n)`; `*` is any tag. All three are usable in atoms and none can be declared. `doc` and `text` are the tree's own tags (its root, and the text no row covers): they cannot be declared either, nor used in atoms. A role may not be called `value`, `^`, `$` or `*`.
171
+
172
+ **`resolve`** names a function you pass to `compile`, the only code a conf ever reaches. A **resolver** gets `{ value, ...roles }`, where `value` is the row's own text and each role is the value of the row its atom took if that row's tag resolves, its text otherwise. It returns a string, the row's value, or `null` for "not this tag after all". Resolvers see the normalised text (NFKC, dashes as `-`, straight quotes, every run of whitespace as one space), and they must be pure, since both halves ask them. Nothing in the JSON is evaluated.
173
+
174
+ **`weak`** and **`fate`** are read only when `dom` chooses. A `weak` tag's rows fill only what the others leave. `fate` marks a tag the fold treats specially:
175
+
176
+ | fate | what it marks | what the fold does |
177
+ |---|---|---|
178
+ | `separator` | glue between words: spaces, dashes, commas | keeps the strongest one where it cut |
179
+ | `connector` | a word that joins what follows it on: `del`, `sul`, `per il` | folds it when what it joins folds |
180
+ | `bracket` | a pair of delimiters and what they hold, `"paren": { "rx": "/\\(.*?\\)/su", "fate": "bracket" }` | empties it when everything inside goes |
181
+
182
+ **Key order is priority.** When two rows tie in `dom`, the tag whose key comes earlier in `tags` wins. `find` never reads the order: reorder the keys and the chart is the same.
183
+
184
+ **Alternatives go longest-first.** The scan offers one match per start position, and a match the word guard rejects (one that would start or end inside a word, see the rules of finding) is not retried shorter, so write `protocollo|prot`, never `prot|protocollo`.
185
+
186
+ **Compile errors** come back as a list of `{ path, tag, message }`, and `compile` never throws. A tag with any error is left out, every other tag keeps working, and a reference to a left-out tag simply finds nothing.
187
+
188
+ <details>
189
+ <summary>What the errors look like</summary>
163
190
 
164
191
  ```ts
165
- const resolvers = {
166
- upper: (p) => p.value.toUpperCase(),
167
- // a bare 1.2.3 is too often something else: a version needs its v or a label
168
- version: (p) => (p.label || p.value.startsWith('v') ? p.value.replace(/^v/, '') : null),
169
- };
192
+ import { compile } from 'fewrd';
193
+
194
+ const { conf, errors } = compile({
195
+ version: 'shop@2',
196
+ tags: {
197
+ sku: { rx: '/%{SKU}/iu', resolve: 'uper' },
198
+ price: { search: [{ from: 'amount', forward: [{ tag: 'cur', optinal: true }] }] },
199
+ cur: { rx: '/€/u' },
200
+ },
201
+ });
202
+ for (const e of errors) console.log(`${e.path} (${e.tag}): ${e.message}`);
203
+ console.log(Object.keys(conf.tags));
204
+ ```
205
+
206
+ ```
207
+ tags.sku.rx (sku): unknown pattern %{SKU}
208
+ tags.sku.resolve (sku): unknown resolver "uper"
209
+ tags.price.search[0].from (price): unknown tag "amount"
210
+ tags.price.search[0].forward[0].optinal (price): unknown key "optinal"
211
+ [ 'cur' ]
212
+ ```
213
+
214
+ The other errors it reports: a tag with both or neither of `rx` and `search`, a search with both or neither of `back` and `forward`, a pattern that is not `/source/flags` or that the regex engine rejects, a circular `%{NAME}`, a reserved name declared as a tag or used as a role, a `fate` that is none of the three, a wrong type, an optional outermost atom, and two regex atoms that could meet.
215
+
216
+ </details>
217
+
218
+ ## The rules of finding
219
+
220
+ These six rules are the whole of what `find(text, conf)` does. It matches on a normalised copy of the text (NFKC, every dash as `-`, straight quotes, every run of whitespace as one space), so a pattern can say `-` and mean any dash, and it maps every span back to your string once, at the end.
221
+
222
+ Each example gives a conf fragment and a text, then the rows it yields (without `^` and `$`). The fragments are shorthand: `from n, back [sp optional, cur]` stands for `{ "from": "n", "back": [{ "tag": "sp", "optional": true }, { "tag": "cur" }] }`, `rx /…/` for a regex atom, and `as x` for a role. The root tags the fragments lean on are `n` `/\d+/`, `w` `/[a-z]+/`, `sp` a single space, `cur` `/€/` (`/[€$]/` where both signs appear), `num` `/#\d+/`, `lbl` `/ref/` and `said` `/said:/`.
223
+
224
+ **Every match of every root tag is a row.** A root tag's pattern is scanned over the whole text, and the scan resumes one code point after each match's start, so overlapping matches of one tag are all rows. Zero-length matches are skipped, a match may not start or end inside a run of letters or digits (the **word guard**), and a root tag with `resolve` keeps only the matches its resolver accepts.
225
+
226
+ ```
227
+ n: /\d+/ on "12 345 x9" → n (0,2) (3,6) ("45" and "9" would cut a word)
228
+ sep: /[ -]+/ on "a - b" → sep (1,4) (2,4) (3,4)
229
+ ```
230
+
231
+ **Searches run from rows.** A search runs once per row of its `from` tag, outward from that row's edge, and yields a row of its own tag spanning everything it took, the `from` row included.
232
+
233
+ ```
234
+ price: from n, back [sp optional, cur] on "€ 5 and €7"
235
+ → price (0,3) (8,10)
236
+ ```
237
+
238
+ **Atoms meet the cursor.** The **cursor** is the edge the search has reached. A tag atom takes a row that meets it: starts there going forward, ends there going back. A regex atom matches the text: between two atoms it must match exactly the gap up to the row the next atom takes, and as the last atom it matches at the cursor. Every way the atoms can be taken is a **derivation**, and all of them are kept: nothing is possessive.
239
+
170
240
  ```
241
+ quote: from said, forward [rx /.+/ as body, $] on "Ann said: see you" → quote (4,17)
242
+ ref: from num, back [sp optional, lbl] on "ref #1 ref#2" → ref (0,6) (7,12)
243
+ ```
244
+
245
+ **Composed rows are filtered like root rows.** A composed tag with `resolve` keeps a derivation only if its resolver accepts `{ value, ...roles }`. Values are worked out on the way and never stored in the chart.
246
+
247
+ ```
248
+ price: resolve euro, from n, back [cur as cur] on "€5 $6" → price (0,2)
249
+ with euro = (p) => (p.cur === '€' ? p.value : null)
250
+ ```
251
+
252
+ **Passes run to a fixpoint.** Pass zero is the root rows. Each later **pass** runs every search against the chart of the pass before, and adds what it found at once. The same tag on the same span is one row, so the loop ends at the **fixpoint**, the first pass that adds nothing. A search may grow from its own tag, and that is how repetition is written.
253
+
254
+ ```
255
+ list: from n, forward [sp, n]; from list, forward [sp, n] on "1 2 3"
256
+ → list (0,3) (0,5) (2,5)
257
+ ```
258
+
259
+ **The chart is complete and neutral.** Rows that cross, **twins** (two tags on one span) and rows inside rows are all kept. Choosing among them is the job of `dom`. The chart depends on the text and the conf only.
260
+
261
+ ```
262
+ ab: /a b/, bc: /b c/, code: /[A-Z]\d{3}/, ref: /[A-Z]\d+/ on "a b c A123"
263
+ → ab (0,3) bc (2,5) code (6,10) ref (6,10)
264
+ ```
265
+
266
+ Why the chart chooses nothing, and why the passes need no declared order, are recorded in [chart-complete-and-neutral](docs/decisions/chart-complete-and-neutral.md) and [passes-from-search-order](docs/decisions/passes-from-search-order.md).
267
+
268
+ ## The chart
269
+
270
+ A chart is a `Chart`, a map from tag to its rows, and nothing else: no values, no roles, no derivations. It keeps these promises:
271
+
272
+ - each tag's rows are sorted by start, then end, one row per tag and span;
273
+ - `^` at `(0,0)` and `$` at `(n,n)` are always there, and a tag with no rows is absent;
274
+ - every span points into your original string, not into the normalised copy `find` matched on;
275
+ - it is immutable: `chart.with(rows)` returns a new chart and shares the tag lists it did not touch;
276
+ - `Chart.from(chart.toJSON())` is the same chart, and the JSON form (tags in code-unit order) survives `JSON.stringify` and `JSON.parse` unchanged.
277
+
278
+ Because it depends on the text and the conf only, you can cache a chart by the text and `conf.version`, and build any number of trees from it. It answers a few questions:
279
+
280
+ | query | answers |
281
+ |---|---|
282
+ | `has(tag, start, end)` | whether that row is in the chart |
283
+ | `after(tag, pos)`, `before(tag, pos)` | the first span of the tag starting at or after `pos`; the last one ending at or before it. `*` stands for any tag, as in atoms |
284
+ | `spans(tag)`, `all()`, `size()` | one tag's rows; every row in position order; how many rows there are, `^` and `$` included |
285
+ | `edges()` | every row laid out by where it starts and where it ends, the question the matcher asks. `edges(rows)` does the same for any subset |
286
+ | `with(rows)`, `toJSON()`, `Chart.from(json)` | a new chart with rows added; the plain form; the chart back from it |
287
+ | `rel(a, b)` | the **Allen relation** of one span to another: one of thirteen names for how two intervals sit (`before`, `meets`, `overlaps`, `starts`, `during`, `finishes`, `equals` and their inverses). The text's edges meet the rows that touch them |
171
288
 
172
- **Errors** come back as `{ path, recipe, entity, message }` — a bad regex or
173
- flag, an unknown or circular fragment, an unknown resolver, a wrong type, a
174
- typo'd key. A recipe with any error is left out; every other recipe keeps
175
- working, in order.
289
+ <details>
290
+ <summary>On the chart of the quick start</summary>
176
291
 
177
292
  ```ts
178
- compile({ version: 'x@1', recipes: [{ entity: 'sku', anchor: '/%{SKU/iu', resolve: 'uper' }] });
179
- // errors:
180
- // recipes[0].anchor (sku) Invalid regular expression: /%{SKU/iu: Incomplete quantifier
181
- // recipes[0].resolve (sku) unknown resolver "uper"
293
+ import { Chart, rel } from 'fewrd';
294
+
295
+ console.log(chart.has('sku-code', 23, 31));
296
+ console.log(chart.after('sep', 22));
297
+ console.log(chart.before('sep', 22));
298
+ console.log(chart.after('*', 23));
299
+ console.log(chart.size());
300
+ console.log([...chart.all()].slice(0, 4));
301
+ console.log(chart.edges().ends.get(18));
302
+ const json = JSON.stringify(chart);
303
+ console.log(JSON.stringify(Chart.from(JSON.parse(json))) === json);
304
+ console.log(rel([18, 31], [23, 31]), rel([15, 18], [18, 31]), rel([0, 0], [0, 6]));
182
305
  ```
183
306
 
184
- A `Book` can also be written directly in TypeScript, with `RegExp`s and
185
- functions in place of strings and names; `read` doesn't care which.
307
+ ```
308
+ true
309
+ [ 22, 23 ]
310
+ [ 17, 18 ]
311
+ [ 23, 31 ]
312
+ 15
313
+ [
314
+ [ '^', [ 0, 0 ] ],
315
+ [ 'sep', [ 6, 7 ] ],
316
+ [ 'sep', [ 15, 18 ] ],
317
+ [ 'sep', [ 16, 18 ] ]
318
+ ]
319
+ [ [ 'sep', [ 15, 18 ] ], [ 'sep', [ 16, 18 ] ], [ 'sep', [ 17, 18 ] ] ]
320
+ true
321
+ finished-by meets meets
322
+ ```
323
+
324
+ </details>
186
325
 
187
- ## Rendering
326
+ ## The tree
327
+
328
+ `dom(text, chart, conf)` takes the chart and returns one reading of it, a tree of plain objects:
188
329
 
189
330
  ```ts
190
- import { gist, html, shown, type Fold } from 'fewrd';
331
+ type Node = {
332
+ tag: string; // a conf tag, or 'doc' (the root) or 'text' (water)
333
+ start: number; end: number; // into your original string
334
+ value?: string; // the resolver's answer, only on tags that resolve
335
+ attrs: Record<string, Node | string>; // roles bound by `as`
336
+ also?: string[]; // tags of twins folded into this node
337
+ fate?: 'separator' | 'connector' | 'bracket'; // the tag's fate, copied from the conf
338
+ text?: string; // your original string, on `doc` only
339
+ children: Node[]; // by containment, in text order, water included
340
+ };
341
+ ```
191
342
 
192
- const fold: Fold = (m) => ['protocol', 'cup'].includes(m.entity);
193
- gist(cuts, fold); // the condensed view as plain text
194
- html(cuts, fold); // every leaf, tagged; what the condensed view drops carries data-fold
195
- shown(cuts, fold); // one boolean per leaf, for your own renderer
343
+ The tree of the quick start, printed one node per line:
344
+
345
+ ```
346
+ doc (0,51) "Refund approved - SKU: abc-1234 - customer notified"
347
+ text (0,6) "Refund"
348
+ sep (6,7) " "
349
+ text (7,15) "approved"
350
+ sep (15,18) " - "
351
+ sku (18,31) "SKU: abc-1234" value="SKU: ABC-1234" label=sku-word(18,21)
352
+ sku-word (18,21) "SKU"
353
+ sep (21,23) ": "
354
+ sku-code (23,31) "abc-1234"
355
+ sep (31,34) " - "
356
+ text (34,42) "customer"
357
+ sep (42,43) " "
358
+ text (43,51) "notified"
196
359
  ```
197
360
 
198
- Mentions come out as `<span data-entity data-mention>`, parts as
199
- `<span data-part>`, separators as `<span data-sep>`, nested as the mentions
200
- nest. Toggle between the full and condensed view with one class:
361
+ <details>
362
+ <summary>The few lines that printed it</summary>
201
363
 
202
- ```css
203
- .condensed [data-fold] { display: none; }
364
+ ```ts
365
+ import type { Node } from 'fewrd';
366
+
367
+ const show = (n: Node, depth = 0): string[] => [
368
+ `${' '.repeat(depth)}${n.tag} (${n.start},${n.end}) ${JSON.stringify(text.slice(n.start, n.end))}` +
369
+ (n.value !== undefined ? ` value=${JSON.stringify(n.value)}` : '') +
370
+ (n.also ? ` also=${n.also}` : '') +
371
+ Object.entries(n.attrs).map(([as, v]) => ` ${as}=${typeof v === 'string' ? JSON.stringify(v) : `${v.tag}(${v.start},${v.end})`}`).join(''),
372
+ ...n.children.flatMap((c) => show(c, depth + 1)),
373
+ ];
374
+ console.log(show(doc).join('\n'));
204
375
  ```
205
376
 
206
- ## Playground
377
+ </details>
207
378
 
208
- Keep a book in a folder next to your code, and open it in the playground with
209
- one command. `fewrd-play` is a separate dev-only package, so none of it ends up
210
- in the library your code ships:
379
+ The `text` nodes are **water**, the stretches of text that no node of the conf's tags covers.
380
+
381
+ **Selection** turns the chart's many readings into one. It walks the rows (all but `^` and `$`) in one fixed order and takes or drops each, and it depends on the chart, the conf and the key order of `tags`, nothing else. The examples below give a conf fragment and a text, then the children of `doc`: `›` opens a node's own children, `last=w(4,5)` is a role holding a node, and `currency="€ "` one holding text.
382
+
383
+ **Order.** Every non-weak row comes before every weak one; within each, the longer span first, then the tag whose key comes earlier in `tags`, then the earlier start.
211
384
 
212
- ```bash
213
- npm install -D fewrd-play
214
385
  ```
215
- ```bash
216
- npx fewrd-play path/to/book --open
386
+ ab: /a b/, bcd: /b c d/ on "a b c d" → text "a ", bcd (2,7) (longer wins)
387
+ ab: /a b/, bc: /b c/ on "a b c" → ab (0,3), text " c" (key order breaks the tie)
388
+ cde: /c d e/ weak, bc: /b c/ on "a b c d e" → text "a ", bc (2,5), text " d e" (weak waits)
389
+ ```
390
+
391
+ **Take or drop.** A row that **crosses** a chosen row (overlaps it without either containing the other) is dropped. A row inside a chosen row of the same tag is dropped too: the outer wins, which is what clears away the suffix matches of the scan (`num` on `1.5` has rows `(0,3)` and `(2,3)`) and the partial rows of a self-grown search. A row with exactly a chosen row's span is a twin: its tag joins that node's `also`. Any other row is chosen.
392
+
393
+ ```
394
+ num: /\d+(?:\.\d+)?/, digit: /\d/ on "1.5" → num (0,3) › digit (0,1), text ".", digit (2,3)
395
+ code: /[A-Z]\d{3}/, ref: /[A-Z]\d+/ on "A123" → code (0,4) also=ref
396
+ ```
397
+
398
+ **Forcing.** When a composed row is chosen, its search is run again inside its own span with the same matcher `find` used, and every row that derivation took is chosen with it, at once. The derivation kept is the first, in the order `find` enumerates them (the tag's searches in order, `from` rows by position, an optional atom tried skipped before taken), that spans the row exactly, that its resolver accepts, and none of whose rows crosses a chosen row; with none, the composed row is dropped. Forced composed rows are forced the same way, down the tree.
399
+
400
+ A forced row of the composed row's own tag is not a node: its own derivation is forced in its place, so the outermost row of a self-grown tag absorbs its chain.
401
+
402
+ ```
403
+ list: from w, forward [sp, w]; from list, forward [sp, w as last] on "a b c"
404
+ → list (0,5) last=w(4,5) › w "a", sp " ", w "b", sp " ", w "c"
405
+ ```
406
+
407
+ **Roles and values.** A composed node's `attrs[as]` is the node its kept derivation took for that atom, or the text a regex atom matched, or the text of an absorbed row. `value` is on nodes whose tag resolves, worked out bottom-up with the same resolvers on the same normalised text. A resolver that refuses in `dom` what it accepted in `find` is a bug, and `dom` throws naming the tag and the span.
408
+
409
+ ```
410
+ price: from n, back [rx /€ ?/ as currency] on "€ 5" → price (0,3) currency="€ " › text "€ ", n (2,3)
411
+ ```
412
+
413
+ **Water.** The chosen rows nest by containment, and every stretch of text no chosen row covers becomes water. So the **leaves** (nodes with no children) cover the text exactly once, in order, and joining them gives the text back; no two nodes cross. The root is `doc`, spanning the whole text and carrying it in `text`, and each node carries its tag's `fate`, so the tree is all the fold needs: a tree that went through `JSON.stringify` and back folds the same.
414
+
415
+ `dom` throws when the chart holds a tag the conf does not declare (a chart of another conf) or a row that does not fall on a boundary of the text (a chart of another text). The decisions behind forcing are in [forcing-absorbs-own-tag](docs/decisions/forcing-absorbs-own-tag.md) and [outer-wins-same-tag](docs/decisions/outer-wins-same-tag.md).
416
+
417
+ ## The rules of the fold
418
+
419
+ A **fold** is a function from a node to a boolean, the reader's policy: `true` for a node to fold away. `hidden(doc, fold)` returns the set of nodes the condensed view drops, and `gist(doc, fold)` the text of the leaves that are not in it. The policy is never asked about `doc`. Four rules apply, in this order, each seeing what the ones before it hid. The examples use this conf:
420
+
421
+ ```json
422
+ {
423
+ "version": "note@1",
424
+ "tags": {
425
+ "ref": { "search": [{ "from": "num", "back": [{ "tag": "sep", "optional": true }, { "tag": "lbl", "as": "label" }] }] },
426
+ "num": { "rx": "/#\\d+/u" },
427
+ "lbl": { "rx": "/ref|n\\./iu" },
428
+ "paren": { "rx": "/\\(.*?\\)/su", "fate": "bracket" },
429
+ "conn": { "rx": "/del|sul|per\\s+il/iu", "fate": "connector" },
430
+ "sep": { "rx": "/(?:\\s|[,;:](?=\\s|$)|(?<=^|\\s)-(?=\\s|$))+/u", "fate": "separator" }
431
+ }
432
+ }
433
+ ```
434
+
435
+ **A node folds when the policy says so, or when an ancestor folds.** Every leaf under it is hidden.
436
+
437
+ ```
438
+ "Nota ref #12 - fine" fold ref → "Nota - fine"
439
+ "Nota ref #12 - fine" fold num → "Nota ref - fine"
217
440
  ```
218
441
 
442
+ **A connector follows its right.** A leaf with the connector fate is hidden when the next leaf to its right that is not a separator is hidden, and stays otherwise. Connectors are decided right to left, so a connector before a hidden connector goes too.
443
+
219
444
  ```
220
- path/to/book/
221
- book.json the recipes; the playground's save button writes it back
222
- cases.json sample texts: [{ "name": "refund", "text": "Refund approved - SKU: abc-1234" }]
223
- cases.local.json more texts, e.g. real ones you keep out of git (optional)
224
- resolvers.ts export const resolvers = { upper: (p) => p.value.toUpperCase() } (optional)
445
+ "Nota del ref #12 - fine" fold ref → "Nota - fine"
446
+ "Nota del ref #12 - fine" fold num → "Nota del ref - fine"
225
447
  ```
226
448
 
227
- `resolvers.ts` is served with its types stripped (Node 22.13+), so it may
228
- import types, `fewrd` itself and other files in the folder, but no other
229
- packages. A `resolvers.js` is served as is. The server listens on localhost
230
- only; `--port` picks the port (default 4747).
449
+ **A bracket empties out.** A node with the bracket fate is hidden, with all it holds, when every leaf inside it that is not a separator is hidden and at least one is. Its **delimiters**, its first and last child when each is water exactly one character long (the `(` and the `)`), do not count and go with it. Brackets are decided innermost first.
450
+
451
+ ```
452
+ "Fornitura toner (ref #12) - saldo" fold ref → "Fornitura toner - saldo"
453
+ "Fornitura toner (ref #12 urgente) - saldo" fold ref → "Fornitura toner (urgente) - saldo"
454
+ ```
231
455
 
232
- Or mount the playground in a page of your own:
456
+ **The strongest separator survives.** Take each run of separators and hidden leaves between two surviving leaves that are not separators. If nothing in the run was hidden, its separators stay untouched, at the edges of the text too. Otherwise only the strongest survives, and a separator is as strong as the strongest mark it contains: `-` over `;` over `:` over `,` over a plain space, the leftmost on a tie. Not even that one survives at an edge of the text, right after the opening delimiter of a bracket that stays, or before closing punctuation (`.` `,` `;` `:` `!` `?` `)` `]` `}`).
457
+
458
+ ```
459
+ "Nota, #12 - fine" fold num → "Nota - fine" "#12 - fine" fold num → "fine"
460
+ "a; #1, b" fold num → "a; b" "Nota (#12 fine)" fold num → "Nota (fine)"
461
+ ```
462
+
463
+ Folding nothing gives the text back, surrounding spaces included. Why a fate rides on the node and not in the conf is in [fates-on-the-node](docs/decisions/fates-on-the-node.md); how connectors became tags is in [connectors-are-tags-with-a-fate](docs/decisions/connectors-are-tags-with-a-fate.md).
464
+
465
+ ## Rendering it yourself
466
+
467
+ fewrd stops at the tree. Both views from one tree take a few lines of yours: `hidden` says which nodes the condensed view drops, and CSS does the switch.
468
+
469
+ ```ts
470
+ import { hidden, type Fold, type Node } from 'fewrd';
471
+
472
+ const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
473
+
474
+ function html(doc: Node, fold: Fold): string {
475
+ const gone = hidden(doc, fold);
476
+ const text = doc.text!;
477
+ const render = (n: Node): string => {
478
+ const inner = n.children.length ? n.children.map(render).join('') : esc(text.slice(n.start, n.end));
479
+ if (n.tag === 'text' && !gone.has(n)) return inner;
480
+ return `<span data-tag="${n.tag}"${gone.has(n) ? ' data-fold' : ''}>${inner}</span>`;
481
+ };
482
+ return doc.children.map(render).join('');
483
+ }
484
+
485
+ console.log(html(doc, (n) => n.tag === 'sku'));
486
+ ```
487
+
488
+ ```css
489
+ .condensed [data-fold] { display: none }
490
+ ```
491
+
492
+ With the class `condensed` on the container the reader sees `Refund approved - customer notified`; without it, the whole text.
493
+
494
+ <details>
495
+ <summary>What it prints on the quick start's tree</summary>
496
+
497
+ ```
498
+ Refund<span data-tag="sep"> </span>approved<span data-tag="sep"> - </span><span data-tag="sku" data-fold><span data-tag="sku-word" data-fold>SKU</span><span data-tag="sep" data-fold>: </span><span data-tag="sku-code" data-fold>abc-1234</span></span><span data-tag="sep" data-fold> - </span>customer<span data-tag="sep"> </span>notified
499
+ ```
500
+
501
+ </details>
502
+
503
+ ## Playground
504
+
505
+ `pnpm dev` opens the playground on twenty-one cases from two domains, thirteen Italian subjects and eight inbox lines, each found with its own conf (`confs/it-pa.json`, `confs/common.json`). One case shows at a time: the text on a character grid with every row of the chart drawn as an underline in its tag's colour, stacked in lanes where rows overlap; a popover with tag, span and value on hover; the gist; the tags as chips that light their rows on hover, keep them lit on click, and fold with an eye; the tree; and the conf editor, a pane on the left from 1700 px wide and a drawer below that, recompiling as you type with errors listed by path.
506
+
507
+ Or mount it in a page of your own:
233
508
 
234
509
  ```ts
235
510
  import { mount } from 'fewrd/playground';
236
511
  import data from './shop.json' with { type: 'json' };
237
512
 
238
513
  mount(document.getElementById('app')!, {
239
- data, // edit the JSON live; errors show inline
240
- resolvers, // your named resolvers
241
- cases: [{ name: 'refund', text: 'Refund approved - SKU: abc-1234 - customer notified' }],
242
- fold: (m) => m.entity === 'sku', // optional: which entities start folded
243
- save: (json) => fetch('/book', { method: 'POST', body: json }), // optional: a save button
514
+ confs: { shop: { conf: data, resolvers: { upper: (p) => p.value.toUpperCase() } } },
515
+ cases: [{ name: 'refund', conf: 'shop', text: 'Refund approved - SKU: abc-1234 - customer notified', fold: ['sku'], gist: 'Refund approved - customer notified' }],
244
516
  });
245
517
  ```
246
518
 
247
- Every edit recompiles the book and re-reads every case; invalid JSON keeps the
248
- last good result on screen, and reset brings back the last saved book. Pass
249
- `book` instead of `data`/`resolvers` for a read-only view of a compiled
250
- `Book`. The playground injects its own scoped styles, so it needs no
251
- stylesheet.
519
+ `confs` are named confs, as data, with their resolvers; every case names the conf it is found with, and may carry the `fold` it opens with and the `gist` that fold should give. The playground injects its own scoped styles and needs no stylesheet. It names Inter, JetBrains Mono and Material Symbols Rounded first and falls back to system fonts and text glyphs; `playground/index.html` loads them from Google Fonts for development only.
520
+
521
+ <details>
522
+ <summary>Every control, in detail</summary>
523
+
524
+ - **The bar** stays on screen while you scroll: ‹ and › step through the cases (so do the ← → keys, outside the editor and the menus), `n / 21` says where you are, a menu jumps to any case, grouped by conf, and a switch cycles the theme through auto, light and dark and remembers it.
525
+ - **The grid** sets the text in a monospace font, one character to a cell, wrapped at spaces to the width of the page. Every row of the chart is an underline in its tag's colour with a tick at each end, and rows that overlap stack in lanes below the line. Rows the tree did not keep are drawn thinner.
526
+ - **The popover.** Hover a band, or Tab to the bands and move along them with ↑ ↓, and a popover above the line gives its tag, its span, its value when it has one, and says when it is a twin or was not kept by the tree. The characters it covers light up, and so does its row in the tree.
527
+ - **The gist** comes next: the folded text, the characters it saves, and, when the fold is the case's own, whether it matches the gist the case expects.
528
+ - **The tag chips**, one per tag of the conf, the tags this tree holds first. Hovering a chip lights that tag's rows on the grid and in the tree; a click keeps it lit, several at once. The eye on the chip folds the tag out of the gist, and the hidden text is struck through on the grid. Tags that are not in this tree are listed after them as plain labels that do nothing. Reset returns to the case's own fold.
529
+ - **The tree** is the `dom` of the case, one line per node with its tag, span, `also`, value and text; hovering a line lights its span on the grid.
530
+ - **The conf editor** recompiles the conf as you type and finds the case again. From 1700 px wide it is a pane on the left; below that it is a drawer opened from the bar, where a badge counts the errors while it is closed. Broken JSON keeps the last good chart on screen; compile errors are listed with their paths, and the tags that compiled are drawn.
531
+
532
+ The design, its states and its colour tokens are in [docs/playground.md](docs/playground.md).
252
533
 
253
- ## Demo books
534
+ </details>
254
535
 
255
- Two books live in this repo as working examples and starting points. They are
256
- not part of the package — copy what you need.
536
+ ## fewrd-play
257
537
 
258
- - [`recipes/common`](recipes/common.json) — what recurs in any inbox, chat or
259
- ticket: URLs, emails, phones, IPv4, ISO dates and times, money in three
260
- formats, percentages, versions, ticket keys, @handles, #hashtags, `Re:`/`Fwd:`
261
- chains and quoted replies. Its [resolvers](recipes/common.ts) mostly say
262
- no: a bare `1.2.3`, an octet over 255, month 13, a phone too short.
263
- - [`recipes/it-pa`](recipes/it-pa.json) — Italian public administration
264
- codes: protocol, CIG, CUP, chapter, amount, date, capitals tags,
265
- «con oggetto».
538
+ `fewrd-play`, the dev-only package under `play/`, opens a folder of your own in the same playground, served from the `fewrd` your project has installed:
266
539
 
267
- Their cases are in [`playground/cases.ts`](playground/cases.ts); the tests read
268
- those same cases.
540
+ ```
541
+ path/to/folder/
542
+ conf.json the conf, as data
543
+ cases.json [{ "name": "refund", "text": "…", "fold": ["sku"], "gist": "…" }] (optional)
544
+ cases.local.json more cases, such as real texts kept out of git (optional)
545
+ resolvers.ts export const resolvers = { … }, or a default export (optional)
546
+ ```
547
+
548
+ ```bash
549
+ fewrd-play path/to/folder --open
550
+ ```
551
+
552
+ `--port` (`-p`) picks the port, 4747 by default; `--open` (`-o`) opens the browser; `--fewrd` serves another `fewrd` build's `dist/`. It listens on 127.0.0.1 only and writes nothing, so edits made in the page stay in the page. It is not published to npm yet. From a checkout of this repository, after `pnpm build`, the same command is `node play/cli.ts path/to/folder --fewrd dist --open`; [play/README.md](play/README.md) has the details.
269
553
 
270
554
  ## Develop
271
555
 
272
556
  ```bash
273
557
  pnpm install
274
- pnpm dev # playground on 5577, both demo books, JSON editable live
275
- pnpm test # node --test, type stripping, no build
276
- pnpm typecheck
277
- pnpm build # minified dist/ + types, what npm gets (runs on publish)
558
+ pnpm typecheck # tsc --strict, no emit
559
+ pnpm test # node --test, TypeScript type-stripped: no build
560
+ pnpm dev # the playground, on http://localhost:5577
561
+ pnpm bench <conf-dir> [texts] # times find and dom apart over a fewrd-play conf folder
278
562
  ```
279
563
 
564
+ That loop has no build step. `pnpm build`, run at publish and before running `fewrd-play` from a checkout, bundles the two public entries with vite into `dist/` and writes their types with `tsc`; `dist/` is never committed. The gate for any change is `pnpm typecheck` and `pnpm test`. It is developed on Node 25 and pnpm 10; the loop needs a Node that runs TypeScript files directly. There is no CI: the gate is run locally. One file runs on its own with `node --test test/fold.test.ts`.
565
+
566
+ **Tests.** The core rules are tested with small synthetic confs, one test per rule (`test/find.test.ts`, `test/dom.test.ts`, `test/fold.test.ts`). The two domain confs are tested through their cases: `test/it-pa.test.ts` and `test/common.test.ts` check the rows the cases must give, and `test/gist.test.ts` checks, for every case, that `gist(dom(text, find(text, conf), conf), fold)` is the gist it carries.
567
+
568
+ **A domain** is a conf as data, `confs/<name>.json`; its resolvers, `confs/<name>.ts`, which compiles the conf and throws on any compile error; its cases, `cases/<name>.json`, each `{ name, conf, text, fold, gist }`; a test that reads them; and a line in `playground/main.ts`. Adding one never changes `src/`. A conf names its resolvers as strings, so a call-graph tool sees every resolver in `confs/*.ts` as never called; `compile` is what checks those names, and an unknown one is a compile error with a test of its own.
569
+
570
+ **Documents.** The rules of finding, selection (under The tree) and the fold above are the single source of truth for `find`, `dom`, `hidden` and `gist`: a change to what those return changes these sections in the same branch. Work goes in rounds where the documents move first ([Docs move first](docs/principles.md#docs-move-first)); a round's spec, plan and tasks are written under `specs/` with the speckit skills in `.claude/skills/`. The badges are kept by hand: the test count from `pnpm test`, the size from `dist/index.js` gzipped after `pnpm build`. The design lives in `docs/`: the [vision](docs/vision.md), the [principles](docs/principles.md), the [architecture](docs/architecture.md), the [glossary](docs/glossary.md), the [decisions](docs/decisions/) and the [playground's design](docs/playground.md). The rounds that got here are recorded in `specs/`, starting from the [rewrite brief](specs/rewrite-brief.md).
571
+
280
572
  ## License
281
573
 
282
574
  [MIT](LICENSE) © 2026 Egildo Tagliareni