fewrd 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Egildo Tagliareni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -8,9 +8,12 @@
8
8
  **Finds what recurs in a string, cuts it loose, and gets you the gist in a
9
9
  few words.**
10
10
 
11
+ [![npm](https://img.shields.io/npm/v/fewrd?style=flat-square&color=78C4B6)](https://www.npmjs.com/package/fewrd)
11
12
  ![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
14
  ![types: strict](https://img.shields.io/badge/types-strict-78C4B6?style=flat-square)
13
- ![tests: 18 passing](https://img.shields.io/badge/tests-18%20passing-78C4B6?style=flat-square)
15
+ ![tests: 59 passing](https://img.shields.io/badge/tests-59%20passing-78C4B6?style=flat-square)
16
+ [![license: MIT](https://img.shields.io/badge/license-MIT-78C4B6?style=flat-square)](LICENSE)
14
17
 
15
18
  </div>
16
19
 
@@ -29,9 +32,56 @@ normalise → anchors → expand → merge → segment ⇒ Cuts (depends
29
32
  render(Cuts, fold) ⇒ gist | tagged html (always dynamic)
30
33
  ```
31
34
 
35
+ ## Install
36
+
37
+ ```bash
38
+ npm install fewrd
39
+ ```
40
+
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.
47
+
48
+ ## Quick start
49
+
50
+ Describe what recurs as a **book** of recipes, in JSON:
51
+
52
+ ```json
53
+ {
54
+ "$schema": "./node_modules/fewrd/book.schema.json",
55
+ "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
+ ]
65
+ }
66
+ ```
67
+
68
+ Compile it once, then read any text with it:
69
+
70
+ ```ts
71
+ import { compile, gist, html, read } from 'fewrd';
72
+ import data from './shop.json' with { type: 'json' };
73
+
74
+ const { book, errors } = compile(data, { resolvers: { upper: (p) => p.value.toUpperCase() } });
75
+
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
80
+ ```
81
+
32
82
  ## See it fold
33
83
 
34
- A real case, unfolded:
84
+ A real Italian public-administration subject, unfolded:
35
85
 
36
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
37
87
 
@@ -44,16 +94,26 @@ before "Trasmissione" — fewrd decides which separator survives a fold and
44
94
  which bracket empties out along with what was inside it. Both strings come
45
95
  from the same `Cuts`; nothing gets re-parsed to produce the second one.
46
96
 
97
+ Or an inbox line, with the `Re:`/`Fwd:` chain and the ticket key folded:
98
+
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
102
+
103
+ …while the money still reads as `1250.00 USD` and the date as `2026-10-15`.
104
+
47
105
  ## The pieces
48
106
 
49
107
  | name | what it is |
50
108
  |---|---|
51
- | `Recipe` | one pattern: a strict `anchor` (the value), closed lists of `left`/`right` neighbours (label, channel, date…), `requires`, `resolve`, `weak`, `rest` |
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` |
52
112
  | `Book` | recipes in priority order, plus a `version` that keys a cached reading |
53
113
  | `Mention` | one recognised thing: its `extent`, its `parts` in text order, a canonical `value`, and its `parent` when nested |
54
114
  | `Leaf` | one piece of the partition: `text`, `sep`, `open`, `close`, or a mention's `part` |
55
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 |
56
- | `Fold` | the caller's policy: which mentions a condensed view drops |
116
+ | `Fold` | your policy: which mentions a condensed view drops |
57
117
 
58
118
  ## The rules
59
119
 
@@ -74,35 +134,149 @@ from the same `Cuts`; nothing gets re-parsed to produce the second one.
74
134
  condensed view drops with `data-fold`. The whole switch is
75
135
  `.condensed [data-fold] { display: none }`.
76
136
 
77
- ## Use
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 |
144
+ |---|---|
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.
78
163
 
79
164
  ```ts
80
- import { read, gist, html } from 'fewrd';
81
- import { itPa } from 'fewrd/recipes/it-pa';
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
+ };
170
+ ```
171
+
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.
82
176
 
83
- const cuts = read(subject, itPa);
84
- const fold = (m) => ['protocol', 'cup'].includes(m.entity);
85
- gist(cuts, fold); // plain text
86
- html(cuts, fold); // tagged, both views
177
+ ```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"
182
+ ```
183
+
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.
186
+
187
+ ## Rendering
188
+
189
+ ```ts
190
+ import { gist, html, shown, type Fold } from 'fewrd';
191
+
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
87
196
  ```
88
197
 
89
- ## Try it
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:
201
+
202
+ ```css
203
+ .condensed [data-fold] { display: none; }
204
+ ```
205
+
206
+ ## Playground
207
+
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:
90
211
 
91
212
  ```bash
92
- pnpm dev # playground on 5577 — every case in playground/cases.ts, folded and whole
213
+ npm install -D fewrd-play
214
+ ```
215
+ ```bash
216
+ npx fewrd-play path/to/book --open
93
217
  ```
94
218
 
95
- ## Recipes
219
+ ```
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)
225
+ ```
226
+
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).
231
+
232
+ Or mount the playground in a page of your own:
233
+
234
+ ```ts
235
+ import { mount } from 'fewrd/playground';
236
+ import data from './shop.json' with { type: 'json' };
237
+
238
+ 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
244
+ });
245
+ ```
96
246
 
97
- `recipes/it-pa.ts` is the first book: Italian public administration codes
98
- (protocol, CIG, CUP, chapter, amount, date, capitals tags, «con oggetto»).
99
- `playground/cases.ts` holds the cases. The tests read those same cases.
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.
100
252
 
101
- ## Work
253
+ ## Demo books
254
+
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.
257
+
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».
266
+
267
+ Their cases are in [`playground/cases.ts`](playground/cases.ts); the tests read
268
+ those same cases.
269
+
270
+ ## Develop
102
271
 
103
272
  ```bash
273
+ pnpm install
274
+ pnpm dev # playground on 5577, both demo books, JSON editable live
104
275
  pnpm test # node --test, type stripping, no build
105
276
  pnpm typecheck
106
- pnpm dev # playground on 5577
107
- pnpm build # dist/ for package consumers (runs automatically on publish)
277
+ pnpm build # minified dist/ + types, what npm gets (runs on publish)
108
278
  ```
279
+
280
+ ## License
281
+
282
+ [MIT](LICENSE) © 2026 Egildo Tagliareni
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "fewrd book",
4
+ "description": "A fewrd recipe book written as data. Compile it with compile(data, { resolvers }) from 'fewrd'.",
5
+ "type": "object",
6
+ "properties": {
7
+ "$schema": {
8
+ "type": "string",
9
+ "description": "Where this schema is, e.g. ./node_modules/fewrd/book.schema.json. Ignored by compile."
10
+ },
11
+ "version": {
12
+ "type": "string",
13
+ "minLength": 1,
14
+ "description": "Keys a cached reading. Change it whenever a change to the recipes can change output."
15
+ },
16
+ "defs": {
17
+ "type": "object",
18
+ "description": "Named pattern fragments: source only, no slashes, no flags. Reference one from any pattern or fragment as %{NAME}; it splices in as one unit.",
19
+ "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
20
+ "additionalProperties": { "type": "string" }
21
+ },
22
+ "recipes": {
23
+ "type": "array",
24
+ "description": "Recipes in priority order.",
25
+ "items": { "$ref": "#/$defs/recipe" }
26
+ }
27
+ },
28
+ "required": ["version", "recipes"],
29
+ "additionalProperties": false,
30
+ "$defs": {
31
+ "pattern": {
32
+ "type": "string",
33
+ "pattern": "^/.*/[dgimsuvy]*$",
34
+ "description": "A regex as /source/flags, e.g. \"/C\\\\.?U\\\\.?P/iu\". %{NAME} splices in a fragment from defs."
35
+ },
36
+ "neighbour": {
37
+ "type": "object",
38
+ "properties": {
39
+ "part": { "type": "string", "minLength": 1, "description": "The part name this neighbour attaches as, e.g. label, date, unit." },
40
+ "rx": { "$ref": "#/$defs/pattern", "description": "Written plainly: the engine pins it to the anchor's edge." }
41
+ },
42
+ "required": ["part", "rx"],
43
+ "additionalProperties": false
44
+ },
45
+ "recipe": {
46
+ "type": "object",
47
+ "properties": {
48
+ "entity": { "type": "string", "minLength": 1, "description": "What this recipe recognises; mentions carry it." },
49
+ "anchor": { "$ref": "#/$defs/pattern", "description": "The value, strict. Its part is value (lead on a rest recipe)." },
50
+ "left": { "type": "array", "items": { "$ref": "#/$defs/neighbour" }, "description": "Neighbours tried leftward from the anchor, in priority order." },
51
+ "right": { "type": "array", "items": { "$ref": "#/$defs/neighbour" }, "description": "Neighbours tried rightward from the anchor, in priority order." },
52
+ "requires": { "type": "array", "items": { "type": "string" }, "description": "Parts that must attach, or the candidate is dropped." },
53
+ "resolve": { "type": "string", "minLength": 1, "description": "Name of a resolver passed to compile: canonical value from the parts, or null (not this entity after all)." },
54
+ "weak": { "type": "boolean", "description": "Fills only the gaps the strong recipes leave." },
55
+ "rest": { "type": "boolean", "description": "The mention runs from its anchor to the end of its level, and what follows is read again inside it. Neighbours are ignored." },
56
+ "glued": { "type": "boolean", "description": "The anchor may start or end inside a word." }
57
+ },
58
+ "required": ["entity", "anchor"],
59
+ "additionalProperties": false
60
+ }
61
+ }
62
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ const G=/[‐-―−]/g,H=/[‘-‟]/g,P=e=>"\u201C\u201D\u201E\u201F".includes(e)?'"':"'";function Q(e){let s="";const o=[];let r=!1,n=0;for(const p of e){if(/\s/.test(p))r||(o.push(n),s+=" "),r=!0;else{const c=p.normalize("NFKC").replace(G,"-").replace(H,P);for(const i of c)o.push(n),s+=i;r=!1}n+=p.length}return o.push(e.length),{text:s,at:o}}const U=/[\p{L}\p{N}]/u,y=e=>e!==void 0&&U.test(e),$=(e,s)=>y(e[s])&&y(e[s-1]),b=(e,s)=>y(e[s-1])&&y(e[s]),w=e=>e.replace(/[gy]/g,""),M=new WeakMap;function k(e,s){let o=M.get(e);return o||M.set(e,o={}),o[s]??=s==="left"?new RegExp(`(?:${e.source})$`,w(e.flags)):s==="right"?new RegExp(e.source,`${w(e.flags)}y`):new RegExp(e.source,`${w(e.flags).replace("d","")}gd`)}function Y(e,s,o,r,n){const p=[{part:"value",span:o}],c=new Set;let i=o[0];for(let u=!0;u;){u=!1;for(const t of s.left??[]){if(c.has(t.part))continue;const l=k(t.rx,"left").exec(e.slice(r,i));if(!l||l[0].length===0)continue;const f=r+l.index;if(!$(e,f)){p.unshift({part:t.part,span:[f,i]}),c.add(t.part),i=f,u=!0;break}}}i=o[1];const a=e.slice(0,n);for(let u=!0;u;){u=!1;for(const t of s.right??[]){if(c.has(t.part))continue;const l=k(t.rx,"right");l.lastIndex=i;const f=l.exec(a);if(!f||f[0].length===0)continue;const d=i+f[0].length;if(!b(e,d)){p.push({part:t.part,span:[i,d]}),c.add(t.part),i=d,u=!0;break}}}return p}function z(e,s,o,r,n){const p=[],c=e.slice(0,r);return s.forEach((i,a)=>{if(i.rest||!!i.weak!==n)return;const u=k(i.anchor,"scan");u.lastIndex=o;for(let t=u.exec(c);t;t=u.exec(c)){const l=[t.index,t.index+t[0].length];if(l[0]===l[1]){u.lastIndex=t.index+1;continue}if(!i.glued&&($(e,l[0])||b(e,l[1]))){u.lastIndex=t.index+1;continue}const f=Y(e,i,l,o,r),d=Object.fromEntries(f.map(h=>[h.part,e.slice(h.span[0],h.span[1])])),g=(i.requires??[]).every(h=>h in d)?i.resolve?i.resolve(d):d.value:null;if(g===null){u.lastIndex=t.index+1;continue}p.push({recipe:a,entity:i.entity,extent:[f[0].span[0],f.at(-1).span[1]],parts:f,value:g})}}),p}const B=(e,s)=>e[0]<s[1]&&s[0]<e[1],R=e=>e.extent[1]-e.extent[0];function _(e,s){const o=[...s];for(const r of[...e].sort((n,p)=>R(p)-R(n)||n.recipe-p.recipe))o.some(n=>B(n.extent,r.extent))||o.push(r);return o.sort((r,n)=>r.extent[0]-n.extent[0])}const E=/(?<sep>(?:\s|[,;:](?=\s|$)|(?<=^|\s)-(?=\s|$))+)|(?<open>[(\[])|(?<close>[)\]])/gu;function N(e,s,o,r,n){const p=(i,a,u)=>{a>i&&n.push({start:i,end:a,kind:u,...r!==void 0?{mention:r}:{}})};E.lastIndex=s;let c=s;for(let i=E.exec(e);i&&i.index<o;i=E.exec(e)){p(c,i.index,"text");const a=Math.min(i.index+i[0].length,o);p(i.index,a,i.groups.sep?"sep":i.groups.open?"open":"close"),c=a}p(c,o,"text")}function W(e,s,o,r,n,p,c){let i;s.forEach((l,f)=>{if(!l.rest)return;const d=k(l.anchor,"scan");d.lastIndex=o;for(let g=d.exec(e);g&&g.index<r;g=d.exec(e)){const h=[g.index,Math.min(g.index+g[0].length,r)];if(!(!l.glued&&($(e,h[0])||b(e,h[1])))){(!i||h[0]<i.at[0])&&(i={recipe:f,at:h});break}}});const a=i?i.at[0]:r,u=_(z(e,s,o,a,!0),_(z(e,s,o,a,!1),[]));let t=o;for(const l of u){N(e,t,l.extent[0],n,c);const f=p.push({entity:l.entity,recipe:l.recipe,extent:l.extent,parts:l.parts,value:l.value,...n!==void 0?{parent:n}:{}})-1;for(const d of l.parts)c.push({start:d.span[0],end:d.span[1],kind:"part",part:d.part,mention:f});t=l.extent[1]}if(N(e,t,a,n,c),i){const l=p.push({entity:s[i.recipe].entity,recipe:i.recipe,extent:[i.at[0],r],parts:[{part:"lead",span:i.at}],value:e.slice(i.at[1],r),...n!==void 0?{parent:n}:{}})-1;c.push({start:i.at[0],end:i.at[1],kind:"part",part:"lead",mention:l}),W(e,s,i.at[1],r,l,p,c)}}function J(e,s){const o=Q(e),r=[],n=[];W(o.text,s.recipes,0,o.text.length,void 0,r,n);const p=([a,u])=>({start:o.at[a],end:o.at[u]}),c=r.map(a=>({...a,extent:p(a.extent),parts:a.parts.map(u=>({part:u.part,span:p(u.span)}))})),i=n.map(a=>({...a,start:o.at[a.start],end:o.at[a.end]})).filter(a=>a.end>a.start);for(const a of c){const u=s.recipes[a.recipe];if(u.rest)a.value=e.slice(a.parts[0].span.end,a.extent.end);else if(!u.resolve){const t=a.parts.find(l=>l.part==="value").span;a.value=e.slice(t.start,t.end)}}return{text:e,book:s.version,mentions:c,leaves:i}}const Z=e=>e.includes("-")?4:e.includes(";")?3:e.includes(":")?2:e.includes(",")?1:0,V=/^[.,;:!?)\]]/;function C(e,s){return K(e,s).keep}function K(e,s){const{text:o,mentions:r,leaves:n}=e,p=r.map(()=>!1);r.forEach((t,l)=>p[l]=s(t,l)||t.parent!==void 0&&p[t.parent]);const c=n.map(t=>t.kind!=="sep"&&!(t.mention!==void 0&&p[t.mention])),i=t=>t.kind==="text"||t.kind==="part",a=[];n.forEach((t,l)=>{if(t.kind==="open")a.push(l);else if(t.kind==="close"&&a.length){const f=a.pop();n.slice(f+1,l).some((d,g)=>i(d)&&c[f+1+g])||(c[f]=c[l]=!1)}});let u=-1;for(let t=0;t<=n.length;t++){if(t<n.length&&(n[t].kind==="sep"||!c[t]))continue;const l=[...Array(t-u-1).keys()].map(g=>u+1+g),f=l.filter(g=>n[g].kind==="sep");if(!(l.length!==f.length))f.forEach(g=>c[g]=!0);else{const g=v=>o.slice(v.start,v.end),h=u<0||t===n.length,m=u>=0&&n[u].kind==="open"||t<n.length&&(n[t].kind==="close"||V.test(g(n[t])));if(!h&&!m&&f.length){const v=f.reduce((O,S)=>Z(g(n[S]))>Z(g(n[O]))?S:O);c[v]=!0}}u=t}return{keep:c,folded:p}}function X(e,s){const o=C(e,s);return e.leaves.map((r,n)=>o[n]?e.text.slice(r.start,r.end):"").join("")}const A=e=>e.replace(/[&<>"]/g,s=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[s]);function ee(e,s){const{keep:o,folded:r}=K(e,s),{mentions:n}=e,p=t=>t===void 0?[]:[...p(n[t].parent),t],c=t=>r[t]&&!(n[t].parent!==void 0&&r[n[t].parent]),i=t=>t?" data-fold":"";let a="";const u=[];return e.leaves.forEach((t,l)=>{const f=p(t.mention);let d=0;for(;d<u.length&&u[d]===f[d];)d++;for(;u.length>d;u.pop())a+="</span>";for(const m of f.slice(d))a+=`<span data-entity="${A(n[m].entity)}" data-mention="${m}"${i(c(m))}>`,u.push(m);const g=A(e.text.slice(t.start,t.end)),h=!o[l]&&!(t.mention!==void 0&&r[t.mention]);t.kind==="part"?a+=`<span data-part="${A(t.part)}">${g}</span>`:t.kind==="text"&&!h?a+=g:a+=`<span data-${t.kind}${i(h)}>${g}</span>`}),a+"</span>".repeat(u.length)}const q={book:{all:["$schema","version","defs","recipes"]},recipe:{all:["entity","anchor","left","right","requires","resolve","weak","rest","glued"]},neighbour:{all:["part","rx"]}},te=/^[A-Za-z_][A-Za-z0-9_]*$/,ne=/%\{([A-Za-z_][A-Za-z0-9_]*)\}/g,x=e=>typeof e=="object"&&e!==null&&!Array.isArray(e),L=e=>typeof e=="string"&&e!=="",se=(e,s)=>e?`${e}.${s}`:s;function j(e,s,o,r){for(const n of Object.keys(e))s.includes(n)||r.push({path:se(o,n),message:`unknown key "${n}"`})}function T(e,s,o=[]){return e.replace(ne,(r,n)=>{const p=[...o,n];if(o.includes(n))throw new Error(`circular fragment ${p.join(" \u2192 ")}`);const c=s.get(n);if(c===void 0)throw new Error(`unknown fragment %{${n}}`);return`(?:${T(c,s,p)})`})}function D(e,s,o,r){if(e===void 0)return void r.push({path:s,message:"is required"});const n=typeof e=="string"&&e[0]==="/"?e.lastIndexOf("/"):-1;if(typeof e!="string"||n<1)return void r.push({path:s,message:"must be a pattern string like /source/flags"});try{return new RegExp(T(e.slice(1,n),o),e.slice(n+1))}catch(p){r.push({path:s,message:p instanceof Error?p.message:String(p)})}}function I(e,s,o){return e===void 0?void o.push({path:s,message:"is required"}):L(e)?e:void o.push({path:s,message:"must be a non-empty string"})}function F(e,s,o,r){if(e!==void 0)return Array.isArray(e)?e.flatMap((n,p)=>{const c=`${s}[${p}]`;if(!x(n))return r.push({path:c,message:"must be an object (a neighbour)"}),[];j(n,q.neighbour.all,c,r);const i=I(n.part,`${c}.part`,r),a=D(n.rx,`${c}.rx`,o,r);return i!==void 0&&a?[{part:i,rx:a}]:[]}):void r.push({path:s,message:"must be an array of neighbours"})}function oe(e,s,o,r,n){if(!x(e))return void n.push({path:s,message:"must be an object (a recipe)"});j(e,q.recipe.all,s,n);const p=I(e.entity,`${s}.entity`,n),c=D(e.anchor,`${s}.anchor`,o,n),i=F(e.left,`${s}.left`,o,n),a=F(e.right,`${s}.right`,o,n);e.requires!==void 0&&!(Array.isArray(e.requires)&&e.requires.every(t=>typeof t=="string"))&&n.push({path:`${s}.requires`,message:"must be an array of strings"});let u;e.resolve!==void 0&&(typeof e.resolve!="string"?n.push({path:`${s}.resolve`,message:"must be a string (a resolver name)"}):Object.hasOwn(r,e.resolve)?u=r[e.resolve]:n.push({path:`${s}.resolve`,message:`unknown resolver "${e.resolve}"`}));for(const t of["weak","rest","glued"])e[t]!==void 0&&typeof e[t]!="boolean"&&n.push({path:`${s}.${t}`,message:"must be a boolean"});if(!(p===void 0||!c))return{entity:p,anchor:c,...i&&{left:i},...a&&{right:a},...Array.isArray(e.requires)&&{requires:e.requires.filter(t=>typeof t=="string")},...u&&{resolve:u},...e.weak===!0&&{weak:!0},...e.rest===!0&&{rest:!0},...e.glued===!0&&{glued:!0}}}function ie(e,s={}){if(!x(e))return{book:{version:"",recipes:[]},errors:[{path:"",message:"must be an object (a book)"}]};const o=[];j(e,q.book.all,"",o);const r=I(e.version,"version",o)??"",n=new Map;if(e.defs!==void 0&&!x(e.defs))o.push({path:"defs",message:"must be an object of name \u2192 pattern source"});else if(e.defs)for(const[c,i]of Object.entries(e.defs))te.test(c)?typeof i!="string"?o.push({path:`defs.${c}`,message:"must be a string (pattern source)"}):n.set(c,i):o.push({path:`defs.${c}`,message:"a fragment name is letters, digits and _, not starting with a digit"});if(!Array.isArray(e.recipes))return o.push({path:"recipes",message:e.recipes===void 0?"is required":"must be an array of recipes"}),{book:{version:r,recipes:[]},errors:o};const p=[];return e.recipes.forEach((c,i)=>{const a=[],u=oe(c,`recipes[${i}]`,n,s.resolvers??{},a),t=x(c)&&L(c.entity)?c.entity:void 0;for(const l of a)o.push({...l,recipe:i,...t!==void 0&&{entity:t}});!a.length&&u&&p.push(u)}),{book:{version:r,recipes:p},errors:o}}export{ie as compile,X as gist,ee as html,J as read,C as shown};
@@ -0,0 +1,39 @@
1
+ import{read as A,compile as C,html as H,gist as F}from"./index.js";function h(e){const t=new Set;for(const r of e.recipes)t.add(r.entity);return[...t]}function J(e,t,r){if(!r)return[];const i=new Set;for(const c of t)A(c.text,e).mentions.forEach((o,u)=>{r(o,u)&&i.add(o.entity)});return h(e).filter(c=>i.has(c))}const l=e=>e.replace(/[&<>"]/g,t=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[t]),b=e=>e instanceof Error?e.message:String(e);function B(e){let t=0;for(let r=0;r<e.length;r++)t=t*31+e.charCodeAt(r)|0;return Math.abs(t)}function D(e){return`<table><thead><tr><th>span</th><th>leaf</th><th>mention</th><th>text</th></tr></thead><tbody>${e.leaves.map(r=>{const i=r.mention===void 0?"":`${r.mention} ${e.mentions[r.mention].entity}`;return`<tr><td>${r.start}\u2013${r.end}</td><td>${r.kind}${r.part?`:${r.part}`:""}</td><td>${i}</td><td><code>${l(JSON.stringify(e.text.slice(r.start,r.end)))}</code></td></tr>`}).join("")}</tbody></table>`}function x(e,t,r,i){try{const c=performance.now(),d=A(t,r),o=(performance.now()-c).toFixed(2);e.innerHTML=`
2
+ <p class="fewrd-tagged">${H(d,i)}</p>
3
+ <p class="fewrd-gist"><span class="fewrd-k">gist</span> ${l(F(d,i))}</p>
4
+ <details><summary>${d.mentions.length} mentions \xB7 ${d.leaves.length} leaves \xB7 ${o} ms</summary>
5
+ <pre>${l(JSON.stringify(d.mentions,null,1))}</pre>
6
+ ${D(d)}
7
+ </details>`}catch(c){e.innerHTML=`<p class="fewrd-error">${l(b(c))}</p>`}}function I(e,t){const{cases:r}=t,i="data"in t?t.resolvers:void 0,c="data"in t?t.save:void 0;let d="data"in t?JSON.stringify(t.data,null,2):"",o,u=[];"book"in t?o=t.book:{book:o,errors:u}=C(t.data,{resolvers:i});const p=new Set(J(o,r,t.fold)),w=n=>p.has(n.entity),a=`fewrd-pg-${Math.random().toString(36).slice(2)}`;e.classList.add(a,"condensed");const S=document.createElement("style");document.head.appendChild(S);function k(){S.textContent=`
8
+ .${a}.condensed [data-fold] { display: none; }
9
+ .${a}:not(.condensed) [data-fold] { text-decoration: line-through; text-decoration-color: rgba(0,0,0,.35); }
10
+ .${a} [data-entity] { background: var(--h); border-radius: 3px; padding: 0 1px; }
11
+ .${a} .fewrd-tagged [data-entity] [data-entity] { box-shadow: inset 0 -2px 0 rgba(0,0,0,.12); }
12
+ .${a} [data-part="value"] { font-weight: 600; }
13
+ .${a} [data-part="label"], .${a} [data-part="channel"], .${a} [data-part="lead"] { opacity: .7; }
14
+ .${a} [data-part="date"] { font-style: italic; }
15
+ .${a} [data-sep] { white-space: pre-wrap; }
16
+ .${a} .fewrd-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
17
+ .${a} .fewrd-folds { min-width: 160px; }
18
+ .${a} .fewrd-editor { margin-bottom: 12px; }
19
+ .${a} .fewrd-book { display: block; width: 100%; box-sizing: border-box; min-height: 18em; resize: vertical; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 2; }
20
+ .${a} .fewrd-errors { margin: 6px 0; padding-left: 18px; font-size: 12px; }
21
+ .${a} .fewrd-errors:empty { display: none; }
22
+ .${a} .fewrd-errors, .${a} .fewrd-error { color: #b3261e; }
23
+ .${a} .fewrd-status { font-size: 12px; opacity: .7; }
24
+ ${h(o).map(n=>`.${a} [data-entity=${JSON.stringify(n)}] { --h: hsl(${B(n)%360}, 70%, 88%); }`).join(`
25
+ `)}
26
+ `}const T="data"in t?`<details class="fewrd-editor" open><summary>recipes <button type="button" class="fewrd-reset">reset</button>${c?' <button type="button" class="fewrd-save">save</button> <span class="fewrd-status"></span>':""}</summary>
27
+ <textarea class="fewrd-book" spellcheck="false"></textarea>
28
+ <ul class="fewrd-errors"></ul>
29
+ </details>`:"";e.innerHTML=`
30
+ <div class="fewrd-controls">
31
+ <label><input type="checkbox" class="fewrd-condensed" checked /> condensed</label>
32
+ <select class="fewrd-folds" multiple></select>
33
+ </div>
34
+ ${T}
35
+ <section class="fewrd-trial">
36
+ <textarea class="fewrd-custom" rows="3" spellcheck="false"></textarea>
37
+ <div class="fewrd-trial-out"></div>
38
+ </section>
39
+ <div class="fewrd-cases"></div>`;const L=e.querySelector(".fewrd-condensed"),m=e.querySelector(".fewrd-folds"),$=e.querySelector(".fewrd-custom"),q=e.querySelector(".fewrd-trial-out"),E=e.querySelector(".fewrd-cases");E.innerHTML=r.map((n,s)=>`<section class="fewrd-case"><h2>${l(r[s].name)}</h2><div class="fewrd-out" data-i="${s}"></div></section>`).join("");const j=Array.from(E.querySelectorAll(".fewrd-out"));function M(){const n=h(o);for(const s of Array.from(p))n.includes(s)||p.delete(s);m.size=Math.max(n.length,1),m.innerHTML=n.map(s=>`<option value="${l(s)}"${p.has(s)?" selected":""}>${l(s)}</option>`).join("")}function y(){j.forEach(n=>x(n,r[Number(n.getAttribute("data-i"))].text,o,w)),x(q,$.value,o,w)}m.addEventListener("change",()=>{p.clear();for(const n of Array.from(m.selectedOptions))p.add(n.value);y()}),L.addEventListener("change",()=>e.classList.toggle("condensed",L.checked)),$.addEventListener("input",()=>x(q,$.value,o,w));const f=e.querySelector(".fewrd-book"),O=e.querySelector(".fewrd-errors");function g(n){O&&(O.innerHTML=n.map(s=>`<li>${l([s.path,s.entity&&`(${s.entity})`].filter(Boolean).join(" "))}${s.path?": ":""}${l(s.message)}</li>`).join(""))}function N(){if(!f)return;let n;try{n=JSON.parse(f.value)}catch(s){return g([{path:"",message:`invalid JSON, still showing the last good book: ${b(s)}`}])}({book:o,errors:u}=C(n,{resolvers:i})),g(u),k(),M(),y()}if(f){f.value=d,f.addEventListener("input",N),e.querySelector(".fewrd-reset").addEventListener("click",s=>{s.preventDefault(),f.value=d,N()});const n=e.querySelector(".fewrd-status");e.querySelector(".fewrd-save")?.addEventListener("click",async s=>{s.preventDefault();const v=f.value;try{JSON.parse(v),await c(v),d=v,n.textContent="saved"}catch(z){n.textContent=`not saved: ${b(z)}`}}),f.addEventListener("input",()=>n&&(n.textContent=""))}g(u),k(),M(),y()}export{h as entities,J as initialSelection,I as mount};
@@ -0,0 +1,26 @@
1
+ import type { Book, CompileError, CompileOptions } from './types.ts';
2
+ /** The keys each object accepts and requires. `book.schema.json` must agree (tested). */
3
+ export declare const KEYS: {
4
+ book: {
5
+ all: string[];
6
+ required: string[];
7
+ };
8
+ recipe: {
9
+ all: string[];
10
+ required: string[];
11
+ };
12
+ neighbour: {
13
+ all: string[];
14
+ required: string[];
15
+ };
16
+ };
17
+ /**
18
+ * Compile a book written as data (typically `JSON.parse` output) into a Book.
19
+ * `resolve` names are looked up in `options.resolvers`; nothing in `data` is
20
+ * ever evaluated as code. A recipe with any error is left out; the others
21
+ * keep their order.
22
+ */
23
+ export declare function compile(data: unknown, options?: CompileOptions): {
24
+ book: Book;
25
+ errors: CompileError[];
26
+ };
@@ -0,0 +1,4 @@
1
+ export type { Book, BookData, CompileError, CompileOptions, Cuts, Leaf, LeafKind, Mention, Neighbour, NeighbourData, Recipe, RecipeData, Resolver, Span, } from './types.ts';
2
+ export { read } from './read.ts';
3
+ export { gist, html, shown, type Fold } from './render.ts';
4
+ export { compile } from './compile.ts';
@@ -0,0 +1,34 @@
1
+ import { type Fold } from './index.ts';
2
+ import type { Book, Resolver } from './types.ts';
3
+ export interface PlaygroundCase {
4
+ name: string;
5
+ text: string;
6
+ }
7
+ interface CommonOptions {
8
+ cases: PlaygroundCase[];
9
+ /** Evaluated once at mount, against `cases`, to seed the fold control's initial selection. Never called again. */
10
+ fold?: Fold;
11
+ }
12
+ export type MountOptions = (CommonOptions & {
13
+ book: Book;
14
+ })
15
+ /** Book data (e.g. parsed JSON) and its resolvers: the playground shows an editor and recompiles on every edit. */
16
+ | (CommonOptions & {
17
+ data: unknown;
18
+ resolvers?: Readonly<Record<string, Resolver>>;
19
+ /** Shows a save button: called with the editor's text once it parses as JSON. Reset then returns to what was saved. */
20
+ save?: (json: string) => unknown;
21
+ });
22
+ /** Distinct entities across a book's recipes, in first-appearance order. */
23
+ export declare function entities(book: Book): string[];
24
+ /** Which entities a supplied fold would fold across the given cases, or none if no fold was supplied. */
25
+ export declare function initialSelection(book: Book, cases: PlaygroundCase[], fold?: Fold): string[];
26
+ /**
27
+ * Mount fewrd's playground UI into `el`, rendering `options.cases` against
28
+ * `options.book` — or against `options.data` compiled with
29
+ * `options.resolvers`, editable live. Self-contained: injects the CSS it
30
+ * needs, scoped to this mount so multiple mounts on one page never collide
31
+ * and unrelated page content is never touched.
32
+ */
33
+ export declare function mount(el: Element, options: MountOptions): void;
34
+ export {};
@@ -41,6 +41,48 @@ export interface Book {
41
41
  version: string;
42
42
  recipes: readonly Recipe[];
43
43
  }
44
+ /** A recipe's `resolve`, supplied by name at compile time. */
45
+ export type Resolver = NonNullable<Recipe['resolve']>;
46
+ /** A `Neighbour` as data. `rx` is a pattern: `"/source/flags"`. */
47
+ export interface NeighbourData {
48
+ part: string;
49
+ rx: string;
50
+ }
51
+ /**
52
+ * A `Recipe` as data. Patterns are `"/source/flags"` strings, where
53
+ * `%{NAME}` splices in `BookData.defs.NAME` as one unit; `resolve` names a
54
+ * function from `CompileOptions.resolvers`.
55
+ */
56
+ export interface RecipeData {
57
+ entity: string;
58
+ anchor: string;
59
+ left?: NeighbourData[];
60
+ right?: NeighbourData[];
61
+ requires?: string[];
62
+ resolve?: string;
63
+ weak?: boolean;
64
+ rest?: boolean;
65
+ glued?: boolean;
66
+ }
67
+ /** A `Book` as data: the JSON a book is written in. */
68
+ export interface BookData {
69
+ $schema?: string;
70
+ version: string;
71
+ /** Name → pattern source (no slashes, no flags), referenced as `%{NAME}`. */
72
+ defs?: Record<string, string>;
73
+ recipes: RecipeData[];
74
+ }
75
+ /** One problem found by `compile`. `recipe` indexes `BookData.recipes`. */
76
+ export interface CompileError {
77
+ /** Where in the data, e.g. `recipes[2].left[0].rx`; `""` is the root. */
78
+ path: string;
79
+ recipe?: number;
80
+ entity?: string;
81
+ message: string;
82
+ }
83
+ export interface CompileOptions {
84
+ resolvers?: Readonly<Record<string, Resolver>>;
85
+ }
44
86
  export interface Mention {
45
87
  entity: string;
46
88
  /** Index into the book's recipes. */
package/package.json CHANGED
@@ -1,25 +1,43 @@
1
1
  {
2
2
  "name": "fewrd",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "Read a string with recipes: anchors, greedy expansion, a cuts index, and a fold-aware render.",
5
+ "license": "MIT",
6
+ "description": "Finds what recurs in a string, cuts it loose, and gets you the gist in a few words.",
7
+ "keywords": [
8
+ "text",
9
+ "segmentation",
10
+ "entity-extraction",
11
+ "regex",
12
+ "recipes",
13
+ "gist",
14
+ "fold"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/egildo/fewrd.git"
19
+ },
20
+ "homepage": "https://github.com/egildo/fewrd#readme",
21
+ "bugs": "https://github.com/egildo/fewrd/issues",
6
22
  "files": [
7
- "dist"
23
+ "dist",
24
+ "book.schema.json"
8
25
  ],
9
26
  "exports": {
10
27
  ".": {
11
- "types": "./dist/src/index.d.ts",
12
- "default": "./dist/src/index.js"
28
+ "types": "./dist/types/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ },
31
+ "./playground": {
32
+ "types": "./dist/types/playground.d.ts",
33
+ "default": "./dist/playground.js"
13
34
  },
14
- "./recipes/*": {
15
- "types": "./dist/recipes/*.d.ts",
16
- "default": "./dist/recipes/*.js"
17
- }
35
+ "./book.schema.json": "./book.schema.json"
18
36
  },
19
37
  "scripts": {
20
38
  "test": "node --test \"test/*.test.ts\"",
21
39
  "typecheck": "tsc",
22
- "build": "tsc -p tsconfig.build.json",
40
+ "build": "vite build --config vite.lib.ts && tsc -p tsconfig.build.json",
23
41
  "prepublishOnly": "npm run build",
24
42
  "dev": "vite playground --port 5577 --strictPort"
25
43
  },
@@ -1,11 +0,0 @@
1
- import type { Book, Recipe } from '../src/index.ts';
2
- export declare const protocol: Recipe;
3
- export declare const cig: Recipe;
4
- export declare const cup: Recipe;
5
- export declare const chapter: Recipe;
6
- export declare const amount: Recipe;
7
- export declare const date: Recipe;
8
- export declare const caps: Recipe;
9
- /** From «con oggetto» to the end of its level, read again inside. */
10
- export declare const quotation: Recipe;
11
- export declare const itPa: Book;
@@ -1,77 +0,0 @@
1
- // Italian public administration: the codes users stuff into a document's
2
- // «oggetto». Labels vary wildly, values never do — so every anchor is strict
3
- // and every label generous. Ported from sibardoc-prototype's subject fold.
4
- const DATE = String.raw `\d{1,2}[\/.\-]\d{1,2}[\/.\-]\d{4}`;
5
- function isoDate(d) {
6
- const m = d.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/);
7
- if (!m)
8
- return null;
9
- const [day, month] = [Number(m[1]), Number(m[2])];
10
- if (day < 1 || day > 31 || month < 1 || month > 12)
11
- return null;
12
- return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
13
- }
14
- const mixed = (v) => /\p{L}/u.test(v) && /\d/.test(v);
15
- /** Words that label another entity — never part of a capitals tag. */
16
- const LABEL_WORDS = String.raw `(?:C\.?I\.?G|C\.?U\.?P|P\.?C\.?F|C\.?D\.?R|PROT|PEC|CAP|CAPITOL[OI]|COD|CODICE|FORN|FORNITORE|IVA|EURO|ID|RIF|DEL)\.?(?![\p{L}])`;
17
- const CAPS_WORD = String.raw `(?!${LABEL_WORDS})\p{Lu}(?:[.'&]?\p{Lu}){2,}\.?`;
18
- export const protocol = {
19
- entity: 'protocol',
20
- anchor: /\d{7}(?:\/\d{4})?/u,
21
- left: [
22
- { part: 'label', rx: /(?:Numero\s+Protocollo|Protocollo(?:\s+n[r°]?\.?)?|Prot\.?\s*(?:n[r°]?\.?)?|Rif\.?)\s*:?\s*/iu },
23
- { part: 'channel', rx: /(?:PEC|Posta\s+certificata|Riscontro\s+a)\s*[:\-]\s*/iu },
24
- ],
25
- right: [{ part: 'date', rx: new RegExp(String.raw `\s*,?\s*del\s+${DATE}`, 'iu') }],
26
- requires: ['label'],
27
- resolve: (p) => p.value.slice(0, 7),
28
- };
29
- export const cig = {
30
- entity: 'cig',
31
- anchor: /[A-Z0-9]{10}/u,
32
- left: [{ part: 'label', rx: /C\.?I\.?G\.?(?:\s+(?:derivato|originario|master))?(?:\s*n\.)?\s*[:\-]?\s*/iu }],
33
- // Unlabelled, one letter and nine digits is a Co.Ge. account, not a CIG.
34
- resolve: (p) => (mixed(p.value) && (p.label || !/^[A-Z]\d{9}$/.test(p.value)) ? p.value : null),
35
- };
36
- export const cup = {
37
- entity: 'cup',
38
- anchor: /[A-Z]\d{2}[A-Z0-9]{12}/u,
39
- left: [{ part: 'label', rx: /C\.?\s?U\.?\s?P\.?(?:\s+(?:derivato|master))?(?:\s*n\.)?\s*[:\-]?\s*/iu }],
40
- };
41
- export const chapter = {
42
- entity: 'chapter',
43
- anchor: /[A-Z]{2}\d{2,3}\.\d{3,4}/u,
44
- left: [{ part: 'label', rx: /(?:Capitol[oi]|Cap\.?)(?:\s+di\s+(?:spesa|entrata))?\s*:?\s*/iu }],
45
- };
46
- export const amount = {
47
- entity: 'amount',
48
- anchor: /\d+(?:\.\d{3})*(?:,\d{1,2})?/u,
49
- left: [{ part: 'label', rx: /(?:€|euro)\s*/iu }],
50
- right: [{ part: 'unit', rx: /\s*€/u }],
51
- resolve: (p) => {
52
- if (!p.label && !p.unit)
53
- return null;
54
- const [int, dec = ''] = p.value.split(',');
55
- return `${int.replace(/\./g, '')}.${dec.padEnd(2, '0')}`;
56
- },
57
- };
58
- export const date = {
59
- entity: 'date',
60
- anchor: new RegExp(DATE, 'u'),
61
- resolve: (p) => isoDate(p.value),
62
- };
63
- export const caps = {
64
- entity: 'caps',
65
- anchor: new RegExp(String.raw `${CAPS_WORD}(?: ${CAPS_WORD})+`, 'u'),
66
- weak: true,
67
- };
68
- /** From «con oggetto» to the end of its level, read again inside. */
69
- export const quotation = {
70
- entity: 'quotation',
71
- anchor: /con oggetto\s*:?\s*/iu,
72
- rest: true,
73
- };
74
- export const itPa = {
75
- version: 'it-pa@1',
76
- recipes: [protocol, cig, cup, chapter, amount, date, caps, quotation],
77
- };
@@ -1,3 +0,0 @@
1
- export type { Book, Cuts, Leaf, LeafKind, Mention, Neighbour, Recipe, Span } from './types.ts';
2
- export { read } from './read.ts';
3
- export { gist, html, shown, type Fold } from './render.ts';
package/dist/src/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { read } from "./read.js";
2
- export { gist, html, shown } from "./render.js";
@@ -1,33 +0,0 @@
1
- // Matching runs on a normalised copy; nothing outside the engine sees it.
2
- // NFKC, dash variants → `-`, curly quotes → straight, every whitespace run →
3
- // one space. `at(i)` maps a copy boundary back to an original one.
4
- const DASHES = /[‐-―−]/g;
5
- const QUOTES = /[‘-‟]/g;
6
- const straight = (q) => ('“”„‟'.includes(q) ? '"' : "'");
7
- export function normalise(original) {
8
- let text = '';
9
- const at = [];
10
- let inSpace = false;
11
- let i = 0;
12
- for (const cp of original) {
13
- if (/\s/.test(cp)) {
14
- if (!inSpace) {
15
- at.push(i);
16
- text += ' ';
17
- }
18
- inSpace = true;
19
- }
20
- else {
21
- const piece = cp.normalize('NFKC').replace(DASHES, '-').replace(QUOTES, straight);
22
- // An expanded code point maps every copy boundary inside it to its start.
23
- for (const ch of piece) {
24
- at.push(i);
25
- text += ch;
26
- }
27
- inSpace = false;
28
- }
29
- i += cp.length;
30
- }
31
- at.push(original.length);
32
- return { text, at };
33
- }
package/dist/src/read.js DELETED
@@ -1,211 +0,0 @@
1
- // The reading: anchors → greedy expansion → merge → segmentation ⇒ Cuts.
2
- //
3
- // Everything here works in the normalised copy's coordinates and maps back to
4
- // the original once, at the end. Nothing depends on anything but the text and
5
- // the book, so a Cuts is cacheable by (text, book.version).
6
- import { normalise } from "./normalise.js";
7
- const WORD = /[\p{L}\p{N}]/u;
8
- const isWord = (ch) => ch !== undefined && WORD.test(ch);
9
- /** A match may not start inside a word: its first char is a word char and so is the one before. */
10
- const cutsWordAtStart = (s, start) => isWord(s[start]) && isWord(s[start - 1]);
11
- /** …nor end inside one. */
12
- const cutsWordAtEnd = (s, end) => isWord(s[end - 1]) && isWord(s[end]);
13
- const bare = (flags) => flags.replace(/[gy]/g, '');
14
- const cache = new WeakMap();
15
- function pinned(rx, side) {
16
- let c = cache.get(rx);
17
- if (!c)
18
- cache.set(rx, (c = {}));
19
- return (c[side] ??=
20
- side === 'left'
21
- ? new RegExp(`(?:${rx.source})$`, bare(rx.flags))
22
- : side === 'right'
23
- ? new RegExp(rx.source, `${bare(rx.flags)}y`)
24
- : new RegExp(rx.source, `${bare(rx.flags).replace('d', '')}gd`));
25
- }
26
- /**
27
- * Grow a candidate outward from its anchor, greedily, inside [from, to).
28
- * ponytail: a `$`-pinned regex rescans the prefix, O(n²) worst case per
29
- * neighbour; fine for subjects of a few thousand chars, reverse-match if not.
30
- */
31
- function expand(s, r, anchor, from, to) {
32
- const parts = [{ part: 'value', span: anchor }];
33
- const attached = new Set();
34
- let cursor = anchor[0];
35
- for (let grew = true; grew;) {
36
- grew = false;
37
- for (const nb of r.left ?? []) {
38
- if (attached.has(nb.part))
39
- continue;
40
- const m = pinned(nb.rx, 'left').exec(s.slice(from, cursor));
41
- if (!m || m[0].length === 0)
42
- continue;
43
- const start = from + m.index;
44
- if (cutsWordAtStart(s, start))
45
- continue;
46
- parts.unshift({ part: nb.part, span: [start, cursor] });
47
- attached.add(nb.part);
48
- cursor = start;
49
- grew = true;
50
- break;
51
- }
52
- }
53
- cursor = anchor[1];
54
- const head = s.slice(0, to);
55
- for (let grew = true; grew;) {
56
- grew = false;
57
- for (const nb of r.right ?? []) {
58
- if (attached.has(nb.part))
59
- continue;
60
- const re = pinned(nb.rx, 'right');
61
- re.lastIndex = cursor;
62
- const m = re.exec(head);
63
- if (!m || m[0].length === 0)
64
- continue;
65
- const end = cursor + m[0].length;
66
- if (cutsWordAtEnd(s, end))
67
- continue;
68
- parts.push({ part: nb.part, span: [cursor, end] });
69
- attached.add(nb.part);
70
- cursor = end;
71
- grew = true;
72
- break;
73
- }
74
- }
75
- return parts;
76
- }
77
- function candidates(s, recipes, from, to, weak) {
78
- const out = [];
79
- const head = s.slice(0, to);
80
- recipes.forEach((r, index) => {
81
- if (r.rest || !!r.weak !== weak)
82
- return;
83
- const re = pinned(r.anchor, 'scan');
84
- re.lastIndex = from;
85
- for (let m = re.exec(head); m; m = re.exec(head)) {
86
- const anchor = [m.index, m.index + m[0].length];
87
- if (anchor[0] === anchor[1]) {
88
- re.lastIndex = m.index + 1;
89
- continue;
90
- }
91
- if (!r.glued && (cutsWordAtStart(s, anchor[0]) || cutsWordAtEnd(s, anchor[1]))) {
92
- re.lastIndex = m.index + 1; // a rejected match must not hide a later one
93
- continue;
94
- }
95
- const parts = expand(s, r, anchor, from, to);
96
- const text = Object.fromEntries(parts.map((p) => [p.part, s.slice(p.span[0], p.span[1])]));
97
- const value = (r.requires ?? []).every((p) => p in text) ? (r.resolve ? r.resolve(text) : text.value) : null;
98
- if (value === null) {
99
- re.lastIndex = m.index + 1;
100
- continue;
101
- }
102
- out.push({
103
- recipe: index,
104
- entity: r.entity,
105
- extent: [parts[0].span[0], parts.at(-1).span[1]],
106
- parts,
107
- value,
108
- });
109
- }
110
- });
111
- return out;
112
- }
113
- const overlaps = (a, b) => a[0] < b[1] && b[0] < a[1];
114
- const size = (c) => c.extent[1] - c.extent[0];
115
- /** Longest extent first, then priority; a loser never overlaps a winner. */
116
- function merge(pool, taken) {
117
- const kept = [...taken];
118
- for (const c of [...pool].sort((a, b) => size(b) - size(a) || a.recipe - b.recipe))
119
- if (!kept.some((k) => overlaps(k.extent, c.extent)))
120
- kept.push(c);
121
- return kept.sort((a, b) => a.extent[0] - b.extent[0]);
122
- }
123
- /**
124
- * The separator grammar: whitespace, `,;:` followed by space or end, and a
125
- * dash standing free. A hyphen inside a word is text. Brackets are their own
126
- * leaves so a fold can empty them.
127
- */
128
- const PIECE = /(?<sep>(?:\s|[,;:](?=\s|$)|(?<=^|\s)-(?=\s|$))+)|(?<open>[(\[])|(?<close>[)\]])/gu;
129
- function segment(s, from, to, mention, out) {
130
- const push = (start, end, kind) => {
131
- if (end > start)
132
- out.push({ start, end, kind, ...(mention !== undefined ? { mention } : {}) });
133
- };
134
- PIECE.lastIndex = from;
135
- let at = from;
136
- for (let m = PIECE.exec(s); m && m.index < to; m = PIECE.exec(s)) {
137
- push(at, m.index, 'text');
138
- const end = Math.min(m.index + m[0].length, to);
139
- push(m.index, end, m.groups.sep ? 'sep' : m.groups.open ? 'open' : 'close');
140
- at = end;
141
- }
142
- push(at, to, 'text');
143
- }
144
- function level(s, recipes, from, to, parent, mentions, leaves) {
145
- // The earliest `rest` anchor closes this level's head.
146
- let rest;
147
- recipes.forEach((r, index) => {
148
- if (!r.rest)
149
- return;
150
- const re = pinned(r.anchor, 'scan');
151
- re.lastIndex = from;
152
- for (let m = re.exec(s); m && m.index < to; m = re.exec(s)) {
153
- const at = [m.index, Math.min(m.index + m[0].length, to)];
154
- if (!r.glued && (cutsWordAtStart(s, at[0]) || cutsWordAtEnd(s, at[1])))
155
- continue;
156
- if (!rest || at[0] < rest.at[0])
157
- rest = { recipe: index, at };
158
- break;
159
- }
160
- });
161
- const limit = rest ? rest.at[0] : to;
162
- const kept = merge(candidates(s, recipes, from, limit, true), merge(candidates(s, recipes, from, limit, false), []));
163
- let at = from;
164
- for (const c of kept) {
165
- segment(s, at, c.extent[0], parent, leaves);
166
- const index = mentions.push({ entity: c.entity, recipe: c.recipe, extent: c.extent, parts: c.parts, value: c.value, ...(parent !== undefined ? { parent } : {}) }) - 1;
167
- for (const p of c.parts)
168
- leaves.push({ start: p.span[0], end: p.span[1], kind: 'part', part: p.part, mention: index });
169
- at = c.extent[1];
170
- }
171
- segment(s, at, limit, parent, leaves);
172
- if (rest) {
173
- const index = mentions.push({
174
- entity: recipes[rest.recipe].entity,
175
- recipe: rest.recipe,
176
- extent: [rest.at[0], to],
177
- parts: [{ part: 'lead', span: rest.at }],
178
- value: s.slice(rest.at[1], to),
179
- ...(parent !== undefined ? { parent } : {}),
180
- }) - 1;
181
- leaves.push({ start: rest.at[0], end: rest.at[1], kind: 'part', part: 'lead', mention: index });
182
- level(s, recipes, rest.at[1], to, index, mentions, leaves);
183
- }
184
- }
185
- /** Read `text` with `book`: every mention, and the partition of the text into leaves. */
186
- export function read(text, book) {
187
- const n = normalise(text);
188
- const raw = [];
189
- const rawLeaves = [];
190
- level(n.text, book.recipes, 0, n.text.length, undefined, raw, rawLeaves);
191
- const span = ([s, e]) => ({ start: n.at[s], end: n.at[e] });
192
- const mentions = raw.map((m) => ({
193
- ...m,
194
- extent: span(m.extent),
195
- parts: m.parts.map((p) => ({ part: p.part, span: span(p.span) })),
196
- }));
197
- const leaves = rawLeaves
198
- .map((l) => ({ ...l, start: n.at[l.start], end: n.at[l.end] }))
199
- .filter((l) => l.end > l.start);
200
- // A value comes from the copy; hand back the original's own characters.
201
- for (const m of mentions) {
202
- const r = book.recipes[m.recipe];
203
- if (r.rest)
204
- m.value = text.slice(m.parts[0].span.end, m.extent.end);
205
- else if (!r.resolve) {
206
- const v = m.parts.find((p) => p.part === 'value').span;
207
- m.value = text.slice(v.start, v.end);
208
- }
209
- }
210
- return { text, book: book.version, mentions, leaves };
211
- }
@@ -1,101 +0,0 @@
1
- // The last pass, always dynamic: a fold policy decides which mentions go, the
2
- // separator fate decides which separators go with them, and the same leaves
3
- // render as a gist or as tagged HTML carrying both views at once.
4
- /** A separator's weight when a fold makes several meet: the strongest stays. */
5
- const strength = (s) => (s.includes('-') ? 4 : s.includes(';') ? 3 : s.includes(':') ? 2 : s.includes(',') ? 1 : 0);
6
- const CLOSING = /^[.,;:!?)\]]/;
7
- /**
8
- * Which leaves the condensed view shows. With nothing folded, every leaf.
9
- *
10
- * - A mention folds when the policy says so, or when the mention holding it folds.
11
- * - A bracket pair folds when nothing between them survives.
12
- * - The separators between two surviving leaves survive untouched when no
13
- * fold fell among them; otherwise only the strongest one survives, and none
14
- * at an edge, after an opening bracket or before closing punctuation.
15
- */
16
- export function shown(cuts, fold) {
17
- return fate(cuts, fold).keep;
18
- }
19
- function fate(cuts, fold) {
20
- const { text, mentions, leaves } = cuts;
21
- const folded = mentions.map(() => false);
22
- mentions.forEach((m, i) => (folded[i] = fold(m, i) || (m.parent !== undefined && folded[m.parent])));
23
- const keep = leaves.map((l) => l.kind !== 'sep' && !(l.mention !== undefined && folded[l.mention]));
24
- const content = (l) => l.kind === 'text' || l.kind === 'part';
25
- // Brackets: pair by stack; a pair with no surviving content inside folds.
26
- const stack = [];
27
- leaves.forEach((l, i) => {
28
- if (l.kind === 'open')
29
- stack.push(i);
30
- else if (l.kind === 'close' && stack.length) {
31
- const o = stack.pop();
32
- if (!leaves.slice(o + 1, i).some((x, k) => content(x) && keep[o + 1 + k]))
33
- keep[o] = keep[i] = false;
34
- }
35
- });
36
- // Separators: each run between two surviving non-separator leaves.
37
- let prev = -1;
38
- for (let i = 0; i <= leaves.length; i++) {
39
- if (i < leaves.length && (leaves[i].kind === 'sep' || !keep[i]))
40
- continue;
41
- const between = [...Array(i - prev - 1).keys()].map((k) => prev + 1 + k);
42
- const seps = between.filter((k) => leaves[k].kind === 'sep');
43
- const cut = between.length !== seps.length;
44
- if (!cut)
45
- seps.forEach((k) => (keep[k] = true));
46
- else {
47
- const slice = (l) => text.slice(l.start, l.end);
48
- const edge = prev < 0 || i === leaves.length;
49
- const hugs = (prev >= 0 && leaves[prev].kind === 'open') || (i < leaves.length && (leaves[i].kind === 'close' || CLOSING.test(slice(leaves[i]))));
50
- if (!edge && !hugs && seps.length) {
51
- const best = seps.reduce((a, b) => (strength(slice(leaves[b])) > strength(slice(leaves[a])) ? b : a));
52
- keep[best] = true;
53
- }
54
- }
55
- prev = i;
56
- }
57
- return { keep, folded };
58
- }
59
- /** The condensed view as plain text. */
60
- export function gist(cuts, fold) {
61
- const keep = shown(cuts, fold);
62
- return cuts.leaves.map((l, i) => (keep[i] ? cuts.text.slice(l.start, l.end) : '')).join('');
63
- }
64
- const escape = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
65
- /**
66
- * Tagged HTML holding both views: every leaf is there, and what the condensed
67
- * view drops carries `data-fold` — so `.condensed [data-fold] { display: none }`
68
- * is the whole switch. Mentions are `<span data-entity data-mention>`, parts
69
- * `<span data-part>`, separators `<span data-sep>`, nested as the mentions nest.
70
- */
71
- export function html(cuts, fold) {
72
- const { keep, folded } = fate(cuts, fold);
73
- const { mentions } = cuts;
74
- const chain = (m) => (m === undefined ? [] : [...chain(mentions[m].parent), m]);
75
- // Marked only where the hiding starts; whatever sits inside inherits it.
76
- const marked = (m) => folded[m] && !(mentions[m].parent !== undefined && folded[mentions[m].parent]);
77
- const tag = (on) => (on ? ' data-fold' : '');
78
- let out = '';
79
- const open = [];
80
- cuts.leaves.forEach((l, i) => {
81
- const want = chain(l.mention);
82
- let common = 0;
83
- while (common < open.length && open[common] === want[common])
84
- common++;
85
- for (; open.length > common; open.pop())
86
- out += '</span>';
87
- for (const m of want.slice(common)) {
88
- out += `<span data-entity="${escape(mentions[m].entity)}" data-mention="${m}"${tag(marked(m))}>`;
89
- open.push(m);
90
- }
91
- const t = escape(cuts.text.slice(l.start, l.end));
92
- const hide = !keep[i] && !(l.mention !== undefined && folded[l.mention]);
93
- if (l.kind === 'part')
94
- out += `<span data-part="${escape(l.part)}">${t}</span>`;
95
- else if (l.kind === 'text' && !hide)
96
- out += t;
97
- else
98
- out += `<span data-${l.kind}${tag(hide)}>${t}</span>`;
99
- });
100
- return out + '</span>'.repeat(open.length);
101
- }
package/dist/src/types.js DELETED
@@ -1,3 +0,0 @@
1
- // The vocabulary. A reading is plain data: no RegExp, no functions, nothing a
2
- // JSON round-trip loses — so it can be cached by (text, book version).
3
- export {};
File without changes
File without changes
File without changes