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 +21 -0
- package/README.md +194 -20
- package/book.schema.json +62 -0
- package/dist/index.js +1 -0
- package/dist/playground.js +39 -0
- package/dist/types/compile.d.ts +26 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/playground.d.ts +34 -0
- package/dist/{src → types}/types.d.ts +42 -0
- package/package.json +28 -10
- package/dist/recipes/it-pa.d.ts +0 -11
- package/dist/recipes/it-pa.js +0 -77
- package/dist/src/index.d.ts +0 -3
- package/dist/src/index.js +0 -2
- package/dist/src/normalise.js +0 -33
- package/dist/src/read.js +0 -211
- package/dist/src/render.js +0 -101
- package/dist/src/types.js +0 -3
- /package/dist/{src → types}/normalise.d.ts +0 -0
- /package/dist/{src → types}/read.d.ts +0 -0
- /package/dist/{src → types}/render.d.ts +0 -0
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
|
+
[](https://www.npmjs.com/package/fewrd)
|
|
11
12
|

|
|
13
|
+

|
|
12
14
|

|
|
13
|
-

|
|
16
|
+
[](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
|
|
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
|
-
| `
|
|
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` |
|
|
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
|
-
##
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
npm install -D fewrd-play
|
|
214
|
+
```
|
|
215
|
+
```bash
|
|
216
|
+
npx fewrd-play path/to/book --open
|
|
93
217
|
```
|
|
94
218
|
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
`
|
|
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
|
-
##
|
|
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
|
|
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
|
package/book.schema.json
ADDED
|
@@ -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=>({"&":"&","<":"<",">":">",'"':"""})[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=>({"&":"&","<":"<",">":">",'"':"""})[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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"
|
|
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/
|
|
12
|
-
"default": "./dist/
|
|
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
|
-
"./
|
|
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
|
},
|
package/dist/recipes/it-pa.d.ts
DELETED
|
@@ -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;
|
package/dist/recipes/it-pa.js
DELETED
|
@@ -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
|
-
};
|
package/dist/src/index.d.ts
DELETED
package/dist/src/index.js
DELETED
package/dist/src/normalise.js
DELETED
|
@@ -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
|
-
}
|
package/dist/src/render.js
DELETED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
File without changes
|
|
File without changes
|
|
File without changes
|