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 +468 -176
- package/dist/index.js +1 -1
- package/dist/playground.js +157 -39
- package/dist/types/chart.d.ts +44 -0
- package/dist/types/conf.d.ts +45 -0
- package/dist/types/derive.d.ts +31 -0
- package/dist/types/dom.d.ts +33 -0
- package/dist/types/find.d.ts +12 -0
- package/dist/types/fold.d.ts +11 -0
- package/dist/types/grid.d.ts +27 -0
- package/dist/types/index.d.ts +5 -4
- package/dist/types/normalise.d.ts +1 -1
- package/dist/types/playground.d.ts +25 -27
- package/package.json +5 -6
- package/book.schema.json +0 -62
- package/dist/types/compile.d.ts +0 -26
- package/dist/types/read.d.ts +0 -3
- package/dist/types/render.d.ts +0 -21
- package/dist/types/types.d.ts +0 -123
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,
|
|
9
|
-
few words.**
|
|
8
|
+
**Finds what recurs in a string, and lets a reader fold it away.**
|
|
10
9
|
|
|
11
10
|
[](https://www.npmjs.com/package/fewrd)
|
|
12
11
|

|
|
13
|
-

|
|
14
13
|

|
|
15
|
-

|
|
16
15
|
[](LICENSE)
|
|
17
16
|
|
|
18
17
|
</div>
|
|
19
18
|
|
|
20
19
|
---
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
A subject line from an Italian public administration, as filed:
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
80
|
+
Compile it once, with the functions it names, then find in any text:
|
|
69
81
|
|
|
70
82
|
```ts
|
|
71
|
-
import { compile,
|
|
83
|
+
import { compile, find, dom, gist } from 'fewrd';
|
|
72
84
|
import data from './shop.json' with { type: 'json' };
|
|
73
85
|
|
|
74
|
-
const {
|
|
86
|
+
const { conf, errors } = compile(data, { resolvers: { upper: (p) => p.value.toUpperCase() } });
|
|
87
|
+
console.log(errors);
|
|
75
88
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
console.log(gist(doc, (n) => n.tag === 'sku'));
|
|
110
|
+
console.log(gist(doc, () => false));
|
|
111
|
+
```
|
|
89
112
|
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
+
## How it thinks
|
|
98
123
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
132
|
+
## The conf
|
|
106
133
|
|
|
107
|
-
|
|
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
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
version:
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
```
|
|
203
|
-
|
|
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
|
-
|
|
377
|
+
</details>
|
|
207
378
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
in the
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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,
|
|
240
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
534
|
+
</details>
|
|
254
535
|
|
|
255
|
-
|
|
256
|
-
not part of the package — copy what you need.
|
|
536
|
+
## fewrd-play
|
|
257
537
|
|
|
258
|
-
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
275
|
-
pnpm test # node --test, type
|
|
276
|
-
pnpm
|
|
277
|
-
pnpm
|
|
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
|