@delurio/cli 0.1.1 → 0.2.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/CHANGELOG.md +21 -0
- package/README.md +28 -0
- package/SPEC.md +9 -7
- package/dist/check.js +20 -0
- package/dist/cli.js +45 -14
- package/dist/schemas.js +256 -0
- package/dist/watch.js +114 -0
- package/package.json +4 -2
- package/schemas/catalog.schema.json +13 -0
- package/schemas/domain.schema.json +305 -0
- package/schemas/flow.schema.json +346 -0
- package/schemas/nav.schema.json +292 -0
- package/schemas/ui.schema.json +250 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
While the version is 0.x, a release that can judge the same graphs differently (a new check, a stricter schema) bumps the minor; a fix that judges nothing differently bumps the patch.
|
|
4
|
+
|
|
5
|
+
## 0.2.0 (2026-10-25)
|
|
6
|
+
|
|
7
|
+
- JSON Schemas (2020-12) for `flow`, `ui`, `nav` (and `nav.extracted.yaml`), `domain` and `catalog` files, shipped in `schemas/`, for editors: completion, hover help from SPEC.md, and errors such as a typo in a key or a title cut short by a comma inside `{ }`.
|
|
8
|
+
- `page --watch` writes the pages again on every change under the folder; `page --serve [--port <n>]` also serves them on 127.0.0.1 and reloads the open page after each write, or says why a graph could not be loaded.
|
|
9
|
+
- `check` validates every graph file against its kind's schema: each place a file does not follow it is an error of the new rule `schema`, at its line and column, which fails `--strict` (exit 2) and works with `--baseline`.
|
|
10
|
+
|
|
11
|
+
## 0.1.1 (2026-10-25)
|
|
12
|
+
|
|
13
|
+
- `page` on a folder writes the page of every graph under it and `index.html` listing them, in one process.
|
|
14
|
+
- A folder that holds no graph gets one line instead of a stack trace; counts read in the singular when there is one.
|
|
15
|
+
- The README runs every command as `npx @delurio/cli`.
|
|
16
|
+
|
|
17
|
+
## 0.1.0 (2026-10-25)
|
|
18
|
+
|
|
19
|
+
- `check`: every graph under a folder, as GNU error lines or SARIF 2.1.0, with `--baseline`; exit 0, 1 or 2.
|
|
20
|
+
- `page`: a graph's page in words, with its guarantees, screens and diagram.
|
|
21
|
+
- `diff`: what a revision changes in a graph, in words, for a pull request.
|
package/README.md
CHANGED
|
@@ -64,6 +64,34 @@ Given a folder of graphs instead, it writes the page of every graph under it, ea
|
|
|
64
64
|
npx @delurio/cli page graphs --out viz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
## Write a graph in an editor
|
|
68
|
+
|
|
69
|
+
The package ships a JSON Schema (2020-12) for each kind of graph file in `schemas/`: `flow`, `ui`, `nav` (also `nav.extracted.yaml`), `domain` and `catalog`. With them an editor completes keys, explains each one from SPEC.md on hover, and marks a typo, a value of the wrong kind, or a title cut short by a comma inside `{ }`. In VS Code, with the YAML extension, put this in `.vscode/settings.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"yaml.schemas": {
|
|
74
|
+
"https://unpkg.com/@delurio/cli@0.2.0/schemas/flow.schema.json": "**/flow.yaml",
|
|
75
|
+
"https://unpkg.com/@delurio/cli@0.2.0/schemas/ui.schema.json": "**/ui.yaml",
|
|
76
|
+
"https://unpkg.com/@delurio/cli@0.2.0/schemas/nav.schema.json": ["**/nav.yaml", "**/nav.extracted.yaml"],
|
|
77
|
+
"https://unpkg.com/@delurio/cli@0.2.0/schemas/domain.schema.json": "**/domain.yaml"
|
|
78
|
+
},
|
|
79
|
+
"json.schemas": [
|
|
80
|
+
{ "fileMatch": ["**/catalog/*.json"], "url": "https://unpkg.com/@delurio/cli@0.2.0/schemas/catalog.schema.json" }
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
With the package installed, `./node_modules/@delurio/cli/schemas/<kind>.schema.json` works the same, offline. `check` reports the same as the editor, as errors of the rule `schema` that fail `--strict`, and finds what a schema cannot see, such as a state that does not exist or a catalog key that is missing.
|
|
86
|
+
|
|
87
|
+
Next to the editor, keep the pages open and current:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx @delurio/cli page graphs --serve # http://127.0.0.1:4600/, written again and reloaded on every save
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`--watch` writes the pages again on every change without serving them. `--serve` listens on 127.0.0.1 only and serves the written pages and nothing else; when a save leaves a graph that cannot be loaded, the open page says why until the next good save.
|
|
94
|
+
|
|
67
95
|
## What a revision changes, in words
|
|
68
96
|
|
|
69
97
|
```sh
|
package/SPEC.md
CHANGED
|
@@ -68,7 +68,7 @@ states: { <state>: { .. } }
|
|
|
68
68
|
assertions: [ { id, never: "<expression>", say, source } ]
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
A flow is a statechart, and its meaning is a subset of SCXML's (W3C Recommendation, 2015). A transition in `on` is an SCXML transition with an event, `always` is a list of eventless transitions, a list of transitions is tried in document order, `invoke` and `type: final` mean what they mean in SCXML, a guard is a `cond` and an effect an `<assign>`; `DONE` and `FAIL.<class>` stand for SCXML's `done.invoke` and `error` events. A flow can therefore be written out as SCXML; that export is
|
|
71
|
+
A flow is a statechart, and its meaning is a subset of SCXML's (W3C Recommendation, 2015). A transition in `on` is an SCXML transition with an event, `always` is a list of eventless transitions, a list of transitions is tried in document order, `invoke` and `type: final` mean what they mean in SCXML, a guard is a `cond` and an effect an `<assign>`; `DONE` and `FAIL.<class>` stand for SCXML's `done.invoke` and `error` events. A flow can therefore be written out as SCXML; that export is [#9](https://github.com/delurio-labs/delurio/issues/9).
|
|
72
72
|
|
|
73
73
|
### Context
|
|
74
74
|
|
|
@@ -117,7 +117,7 @@ A configuration is a state together with a value for every context variable. The
|
|
|
117
117
|
|
|
118
118
|
Each assertion is `never: <expression>`: it holds when no reachable configuration satisfies the expression. A violation is reported with the shortest event path from a starting situation. Under `--strict` a violated assertion exits 2.
|
|
119
119
|
|
|
120
|
-
The checker does not yet fail on unreachable states, transitions no situation takes, or states with no way out; the page already shows them. That is
|
|
120
|
+
The checker does not yet fail on unreachable states, transitions no situation takes, or states with no way out; the page already shows them. That is [#2](https://github.com/delurio-labs/delurio/issues/2). Paths that cover every transition are [#4](https://github.com/delurio-labs/delurio/issues/4).
|
|
121
121
|
|
|
122
122
|
### Several flows
|
|
123
123
|
|
|
@@ -177,7 +177,7 @@ rules: [ { id, rule } ]
|
|
|
177
177
|
|
|
178
178
|
A `role` is a WAI-ARIA 1.2 role, and `name` is the accessible name that Accessible Name and Description Computation 1.2 gives the node; `kit check` reads both from the browser's accessibility tree.
|
|
179
179
|
|
|
180
|
-
A node is shown in a state when the state is in `in`, not in `unless`, and `when` holds. `edges` records relations the page and the checks use: a node labelled by another, an action that leads to a state. `rules` are written but no tool reads them yet; checking them, together with dialogs, focus return, confirmation before a destructive action and events handled where shown, is
|
|
180
|
+
A node is shown in a state when the state is in `in`, not in `unless`, and `when` holds. `edges` records relations the page and the checks use: a node labelled by another, an action that leads to a state. `rules` are written but no tool reads them yet; checking them, together with dialogs, focus return, confirmation before a destructive action and events handled where shown, is [#3](https://github.com/delurio-labs/delurio/issues/3).
|
|
181
181
|
|
|
182
182
|
### Catalog
|
|
183
183
|
|
|
@@ -231,11 +231,13 @@ invariants:
|
|
|
231
231
|
|
|
232
232
|
A capture may carry more words of its own on a field (`owner`, `note`, `editableBy`, `matching`, `opaque`). They are kept and compared, and no tool reads them.
|
|
233
233
|
|
|
234
|
+
An action, a refusal and an invariant may carry `note`: the author's own words, which no tool reads. An invariant the code does not keep is still written, with `holds: false` and the `finding` that reports it: the finding's id in the graph's report, such as `MS-7`. A capture describes the code, so the invariant says what should hold and the finding says where it does not. Decided on 25 Oct 2026.
|
|
235
|
+
|
|
234
236
|
**The API.** An action with a `route` (`METHOD /path`) is one OpenAPI 3.1 operation: its name is the `operationId`, `needs` its security requirement, `query` (a JSON Schema object) its query parameters, `body` its request body, `result` its response (`returns` an entity, a `redirect`, a `status`, or a JSON Schema), and each refusal one more response. When a refusal has a body, it is RFC 9457 problem details with the refusal code as its `type`. So a domain can be written out as an OpenAPI document, and an app that already publishes one names it at the top (`openapi: <file>`) and each action's operation by `operationId`, instead of copying them. An action with no route is a step inside the service. `needs`, `policies`, `sideEffects`, `refusals.<code>.redirect` and `invariants` are delurio's own: no standard says who may do what to which data, or what must always hold.
|
|
235
237
|
|
|
236
|
-
This form was decided on 22 Oct 2026. Graphs captured before it may still give fields as `{ kind: text }`; they are converted when a tool first reads fields. The OpenAPI export and import are
|
|
238
|
+
This form was decided on 22 Oct 2026. Graphs captured before it may still give fields as `{ kind: text }`; they are converted when a tool first reads fields. The OpenAPI export and import are [#8](https://github.com/delurio-labs/delurio/issues/8).
|
|
237
239
|
|
|
238
|
-
No tool checks a domain yet: the revision diff compares it and anchors lock its `source:` keys. Loading it with the flow and checking policies, secrets and `invoke` names is
|
|
240
|
+
No tool checks a domain yet: the revision diff compares it and anchors lock its `source:` keys. Loading it with the flow and checking policies, secrets and `invoke` names is [#5](https://github.com/delurio-labs/delurio/issues/5); generating it from Drizzle and Zod is [#6](https://github.com/delurio-labs/delurio/issues/6); a registry that every API operation and route is covered by a graph is [#7](https://github.com/delurio-labs/delurio/issues/7).
|
|
239
241
|
|
|
240
242
|
## 6. Navigation (`kind: nav`)
|
|
241
243
|
|
|
@@ -324,12 +326,12 @@ graphs/product/flow.yaml:88:7: note: step 2: CODE_CHANGED from developing, with
|
|
|
324
326
|
|
|
325
327
|
The same input gives the same bytes: no times, no random ids, results in a fixed order. Every file written is valid against the OASIS schema.
|
|
326
328
|
|
|
327
|
-
There is no other machine format: no JSON of delurio's own, and nothing that only one CI reads. A converter for a CI that reads neither (GitLab's Code Quality report, Bitbucket's Code Insights) is written when a customer needs it, outside the checker. The public, pinned, offline CLI that gives both outputs is
|
|
329
|
+
There is no other machine format: no JSON of delurio's own, and nothing that only one CI reads. A converter for a CI that reads neither (GitLab's Code Quality report, Bitbucket's Code Insights) is written when a customer needs it, outside the checker. The public, pinned, offline CLI that gives both outputs is `@delurio/cli`, built from this repository; what its first release left out is [#1](https://github.com/delurio-labs/delurio/issues/1).
|
|
328
330
|
|
|
329
331
|
## 11. Writing a graph
|
|
330
332
|
|
|
331
333
|
- Model the few cases that change behaviour; a guard over hundreds of permission codes is wrong.
|
|
332
|
-
- Quote a YAML list item that contains `: `, or YAML reads it as a map.
|
|
334
|
+
- Quote a YAML list item that contains `: `, or YAML reads it as a map. Quote a value inside `{ }` that contains `, ` too, or YAML cuts it there and reads the rest as a key: `{ type: final, title: "Paid, and sent" }`.
|
|
333
335
|
- Say every rule that must hold as an assertion; do not leave it implied by the drawing.
|
|
334
336
|
- In capture mode, describe what the code does today: no new feature, no fix. An invariant the code breaks is modelled as the code has it and reported, not written as an assertion that fails.
|
|
335
337
|
- One process, one flow. A process that runs beside another starts it (`starts:`) or waits for it (`invoke: { flow }`).
|
package/dist/check.js
CHANGED
|
@@ -14,6 +14,7 @@ import { checkStarts } from './starts.js';
|
|
|
14
14
|
import { checkChanges } from './changes.js';
|
|
15
15
|
import { KitError, resolveKit } from './kit.js';
|
|
16
16
|
import { checkGoals, findings, loadNav, viewOf } from './nav.js';
|
|
17
|
+
import { schemaKindOf, validateFile } from './schemas.js';
|
|
17
18
|
export const CHECK_USAGE = [
|
|
18
19
|
' node src/cli.ts check <graphs folder> [--strict] [--baseline <sarif>] [--format sarif --output <file>]',
|
|
19
20
|
' checks every graph under the folder and writes nothing else: GNU error lines on stdout, or SARIF 2.1.0;',
|
|
@@ -24,6 +25,7 @@ const SKIP = new Set(['viz', 'video', 'recording', 'node_modules', '.git']);
|
|
|
24
25
|
const RULES = [
|
|
25
26
|
{ id: 'load', text: 'The graph cannot be loaded', level: 'error' },
|
|
26
27
|
{ id: 'format-newer', text: 'The graph is written for a newer format than this checker reads', level: 'error' },
|
|
28
|
+
{ id: 'schema', text: "A graph file does not follow its kind's schema (SPEC.md)", level: 'error' },
|
|
27
29
|
{ id: 'assertion-violated', text: 'A guarantee of the flow is broken by a reachable situation', level: 'error' },
|
|
28
30
|
{ id: 'main-story-unwalkable', text: 'The declared main story cannot be walked', level: 'error' },
|
|
29
31
|
{ id: 'catalog-key-missing', text: 'A catalog key the screens use is missing', level: 'error' },
|
|
@@ -65,6 +67,19 @@ export function graphDirs(root, out = []) {
|
|
|
65
67
|
}
|
|
66
68
|
return out;
|
|
67
69
|
}
|
|
70
|
+
// every file under root that a schema covers (flow, ui, nav, nav.extracted, domain, catalog), in path order
|
|
71
|
+
function graphFiles(root, out = []) {
|
|
72
|
+
for (const name of readdirSync(root).sort()) {
|
|
73
|
+
if (SKIP.has(name) || name.startsWith('.'))
|
|
74
|
+
continue;
|
|
75
|
+
const path = join(root, name);
|
|
76
|
+
if (statSync(path).isDirectory())
|
|
77
|
+
graphFiles(path, out);
|
|
78
|
+
else if (schemaKindOf(path) !== null)
|
|
79
|
+
out.push(path);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
68
83
|
// the format the file says it is written in: null when it is this one
|
|
69
84
|
function newerFormat(abs) {
|
|
70
85
|
const doc = parse(readFileSync(abs, 'utf8'));
|
|
@@ -239,6 +254,11 @@ export function checkMain(args) {
|
|
|
239
254
|
results.push(result('load', file, linePos === undefined ? { line: 1, column: 1 } : { line: linePos.line, column: linePos.col }, err.message.split('\n')[0], rel(d), 'load'));
|
|
240
255
|
}
|
|
241
256
|
}
|
|
257
|
+
// every graph file against its kind's schema: what an editor with the schemas marks, check reports
|
|
258
|
+
for (const file of graphFiles(root)) {
|
|
259
|
+
for (const f of validateFile(file))
|
|
260
|
+
results.push(result('schema', file, f, f.message, f.graph ?? rel(file), f.element));
|
|
261
|
+
}
|
|
242
262
|
results.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
|
|
243
263
|
if (baseline !== undefined) {
|
|
244
264
|
const prints = baselinePrints(baseline);
|
package/dist/cli.js
CHANGED
|
@@ -19,12 +19,15 @@ import { DIFF_USAGE, diffMain } from './diff.js';
|
|
|
19
19
|
import { KitError, resolveKit } from './kit.js';
|
|
20
20
|
import { CHECK_USAGE, checkMain, count, graphDirs } from './check.js';
|
|
21
21
|
import { renderIndexPage } from './index-page.js';
|
|
22
|
+
import { watchPages } from './watch.js';
|
|
22
23
|
const USAGE = [
|
|
23
24
|
'usage:',
|
|
24
25
|
CHECK_USAGE,
|
|
25
|
-
' delurio page <graph dir | graphs folder> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict]',
|
|
26
|
+
' delurio page <graph dir | graphs folder> [--out <dir>] [--kit <kit>] [--locale <code>] [--strict] [--watch] [--serve [--port <n>]]',
|
|
26
27
|
' writes the graph\'s page: <out>/index.html and its diagram (default out: <graph dir>/viz); on a folder of graphs,',
|
|
27
|
-
' the page of each at <out>/<its path> and an index of them all at <out>/index.html (default out: <folder>/viz)',
|
|
28
|
+
' the page of each at <out>/<its path> and an index of them all at <out>/index.html (default out: <folder>/viz);',
|
|
29
|
+
' --watch writes them again on every change under the folder; --serve also shows them at http://127.0.0.1:<port>/',
|
|
30
|
+
' (default 4600) and reloads the open page after each write',
|
|
28
31
|
DIFF_USAGE.replaceAll('node src/cli.ts', 'delurio'),
|
|
29
32
|
].join('\n').replaceAll('node src/cli.ts', 'delurio');
|
|
30
33
|
const flag = (args, name) => {
|
|
@@ -94,13 +97,13 @@ const isGraph = (dir) => existsSync(join(dir, 'flow.yaml')) || existsSync(join(d
|
|
|
94
97
|
// every graph under the folder, each into <out>/<its path>, and the index of them all. A graph that cannot be loaded
|
|
95
98
|
// is listed as such and the rest are still written: exit 1 when one cannot be loaded, else 2 under --strict when one
|
|
96
99
|
// would fail on its own, else 0.
|
|
97
|
-
async function treePages(root, args) {
|
|
100
|
+
async function treePages(root, out, args) {
|
|
98
101
|
const dirs = graphDirs(root);
|
|
99
102
|
if (dirs.length === 0) {
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
const message = `${root} is not a graph and holds none: there is no flow.yaml or nav.yaml in it or under it`;
|
|
104
|
+
console.error(`error: ${message}`);
|
|
105
|
+
return { code: 1, message };
|
|
102
106
|
}
|
|
103
|
-
const out = resolve(flag(args, '--out') ?? join(root, 'viz'));
|
|
104
107
|
const entries = [];
|
|
105
108
|
let broken = false;
|
|
106
109
|
let failing = false;
|
|
@@ -113,7 +116,7 @@ async function treePages(root, args) {
|
|
|
113
116
|
failing ||= code !== 0;
|
|
114
117
|
}
|
|
115
118
|
catch (err) {
|
|
116
|
-
if (!(err
|
|
119
|
+
if (!loadError(err))
|
|
117
120
|
throw err;
|
|
118
121
|
broken = true;
|
|
119
122
|
const message = err.message.split('\n')[0].replace(/:$/, '');
|
|
@@ -124,7 +127,27 @@ async function treePages(root, args) {
|
|
|
124
127
|
mkdirSync(out, { recursive: true });
|
|
125
128
|
writeFileSync(join(out, 'index.html'), renderIndexPage(basename(resolve(root)), entries));
|
|
126
129
|
console.log(`wrote ${join(out, 'index.html')} and the pages of ${count(entries.length - entries.filter((e) => e.error !== null).length, 'graph')} under ${out}`);
|
|
127
|
-
|
|
130
|
+
const firstError = entries.find((e) => e.error !== null);
|
|
131
|
+
return { code: broken ? 1 : failing ? 2 : 0, message: firstError === undefined ? '' : `${firstError.path}: ${firstError.error}` };
|
|
132
|
+
}
|
|
133
|
+
const loadError = (err) => err instanceof GraphError || err instanceof ModelError || err instanceof KitError || (err instanceof Error && err.name === 'YAMLParseError');
|
|
134
|
+
// one graph's page, or the pages of every graph under the folder
|
|
135
|
+
async function writePages(dir, out, args) {
|
|
136
|
+
if (!isGraph(dir))
|
|
137
|
+
return treePages(dir, out, args);
|
|
138
|
+
try {
|
|
139
|
+
const flow = existsSync(join(dir, 'flow.yaml'));
|
|
140
|
+
const { code } = flow ? await flowPage(dir, out, args) : navPage(dir, out, args);
|
|
141
|
+
console.log(`wrote ${join(out, 'index.html')} and ${join(out, flow ? 'flow.mmd' : 'nav.mmd')}`);
|
|
142
|
+
return { code, message: '' };
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (!loadError(err))
|
|
146
|
+
throw err;
|
|
147
|
+
const message = err.message.split('\n')[0].replace(/:$/, '');
|
|
148
|
+
console.error(`error: ${message}`);
|
|
149
|
+
return { code: 1, message };
|
|
150
|
+
}
|
|
128
151
|
}
|
|
129
152
|
async function main() {
|
|
130
153
|
const args = process.argv.slice(2);
|
|
@@ -134,7 +157,7 @@ async function main() {
|
|
|
134
157
|
return diffMain(args.slice(1));
|
|
135
158
|
if (args[0] === 'page') {
|
|
136
159
|
const rest = args.slice(1);
|
|
137
|
-
const withValue = ['--out', '--kit', '--locale'];
|
|
160
|
+
const withValue = ['--out', '--kit', '--locale', '--port'];
|
|
138
161
|
const dir = rest.find((a, i) => !a.startsWith('--') && !withValue.includes(rest[i - 1] ?? ''));
|
|
139
162
|
if (dir === undefined) {
|
|
140
163
|
console.error(USAGE);
|
|
@@ -144,12 +167,20 @@ async function main() {
|
|
|
144
167
|
console.error(`error: ${dir} is not a folder`);
|
|
145
168
|
return 1;
|
|
146
169
|
}
|
|
147
|
-
if (!isGraph(dir))
|
|
148
|
-
return treePages(dir, rest);
|
|
149
170
|
const out = resolve(flag(rest, '--out') ?? join(dir, 'viz'));
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
const first = await writePages(dir, out, rest);
|
|
172
|
+
const serve = rest.includes('--serve');
|
|
173
|
+
if (!serve && !rest.includes('--watch'))
|
|
174
|
+
return first.code;
|
|
175
|
+
const port = Number(flag(rest, '--port') ?? 4600);
|
|
176
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
177
|
+
console.error(`error: --port is a port number, not ${flag(rest, '--port')}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
return watchPages(dir, out, async () => {
|
|
181
|
+
const again = await writePages(dir, out, rest);
|
|
182
|
+
return { ok: again.code !== 1, message: again.message };
|
|
183
|
+
}, serve ? port : null);
|
|
153
184
|
}
|
|
154
185
|
console.error(USAGE);
|
|
155
186
|
return args.length === 0 || args.includes('--help') ? 0 : 1;
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The JSON Schemas of schemas/ (one per kind of graph file, SPEC.md §12) as `check` applies them: a file is validated
|
|
2
|
+
// against its kind's schema, and each failure is placed at its line and column through the YAML document and said in
|
|
3
|
+
// words a writer can act on: the key that is not one, the key it was probably meant to be, why an if/then rule exists
|
|
4
|
+
// (its `$comment`), and the comma that cut a value inside `{ }`. The schemas are read from the package's own folder,
|
|
5
|
+
// beside src/ and dist/ alike.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
8
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
9
|
+
import addFormatsModule from 'ajv-formats';
|
|
10
|
+
import { isMap, isSeq, LineCounter, parse, parseDocument } from 'yaml';
|
|
11
|
+
const FORMAT = 'delurio-graph/0';
|
|
12
|
+
const KINDS = ['flow', 'ui', 'domain', 'nav', 'catalog'];
|
|
13
|
+
// the kind of graph file, by its name: null for a file no schema covers (binding, tutorial, kit, anchors.lock)
|
|
14
|
+
export function schemaKindOf(file) {
|
|
15
|
+
const name = basename(file);
|
|
16
|
+
if (name === 'flow.yaml' || name === 'ui.yaml' || name === 'domain.yaml')
|
|
17
|
+
return name.slice(0, -'.yaml'.length);
|
|
18
|
+
if (name === 'nav.yaml' || name === 'nav.extracted.yaml')
|
|
19
|
+
return 'nav';
|
|
20
|
+
if (name.endsWith('.json') && basename(dirname(file)) === 'catalog')
|
|
21
|
+
return 'catalog';
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let compiled = null;
|
|
25
|
+
// compiled once, on first use; strictRequired is off because an if/then names keys its branch does not declare
|
|
26
|
+
function schemas() {
|
|
27
|
+
if (compiled !== null)
|
|
28
|
+
return compiled;
|
|
29
|
+
const ajv = new Ajv2020({ allErrors: true, verbose: true, strict: true, strictRequired: false, allowUnionTypes: true });
|
|
30
|
+
addFormatsModule.default(ajv);
|
|
31
|
+
const roots = {};
|
|
32
|
+
const validators = {};
|
|
33
|
+
for (const kind of KINDS)
|
|
34
|
+
roots[kind] = JSON.parse(readFileSync(new URL(`../schemas/${kind}.schema.json`, import.meta.url), 'utf8'));
|
|
35
|
+
for (const kind of KINDS)
|
|
36
|
+
validators[kind] = ajv.compile(roots[kind]);
|
|
37
|
+
compiled = { roots, validators };
|
|
38
|
+
return compiled;
|
|
39
|
+
}
|
|
40
|
+
const steps = (pointer) => (pointer === '' ? [] : pointer.slice(1).split('/').map((t) => t.replace(/~1/g, '/').replace(/~0/g, '~')));
|
|
41
|
+
const escape = (step) => step.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
42
|
+
const within = (path, base) => path === base || path.startsWith(`${base}/`);
|
|
43
|
+
const keyOf = (pair) => String(pair.key?.value ?? pair.key);
|
|
44
|
+
// the YAML node at a JSON pointer; with `name`, the key `name` of the map there, that map and the key's value
|
|
45
|
+
function place(doc, pointer, name) {
|
|
46
|
+
let node = doc.contents;
|
|
47
|
+
for (const t of steps(pointer)) {
|
|
48
|
+
const next = isMap(node) ? node.items.find((p) => keyOf(p) === t)?.value : isSeq(node) ? node.items[Number(t)] : undefined;
|
|
49
|
+
if (next === undefined || next === null)
|
|
50
|
+
break;
|
|
51
|
+
node = next;
|
|
52
|
+
}
|
|
53
|
+
if (name === undefined || !isMap(node))
|
|
54
|
+
return { at: node };
|
|
55
|
+
const pair = node.items.find((p) => keyOf(p) === name);
|
|
56
|
+
return { at: pair?.key ?? node, map: node, value: pair?.value };
|
|
57
|
+
}
|
|
58
|
+
// a schema and the ones its `$ref`s name, inside the same file
|
|
59
|
+
function chain(root, schema) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (let s = schema; s !== null && typeof s === 'object' && out.length < 8;) {
|
|
62
|
+
const here = s;
|
|
63
|
+
out.push(here);
|
|
64
|
+
const ref = here.$ref;
|
|
65
|
+
s = typeof ref === 'string' && ref.startsWith('#/') ? ref.slice(2).split('/').reduce((x, k) => x?.[k], root) : undefined;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
// edits between two names, a swap of two neighbours counting as one (titel, title)
|
|
70
|
+
function distance(a, b) {
|
|
71
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
72
|
+
for (let j = 1; j <= b.length; j++)
|
|
73
|
+
d[0][j] = j;
|
|
74
|
+
for (let i = 1; i <= a.length; i++) {
|
|
75
|
+
for (let j = 1; j <= b.length; j++) {
|
|
76
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
77
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
78
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return d[a.length][b.length];
|
|
82
|
+
}
|
|
83
|
+
const KIND_WORDS = { object: 'an object', array: 'a list', string: 'a string', integer: 'an integer', number: 'a number', boolean: 'true or false', null: 'null' };
|
|
84
|
+
const either = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} or ${xs[xs.length - 1]}`);
|
|
85
|
+
const quoted = (xs) => either(xs.map((x) => JSON.stringify(x)));
|
|
86
|
+
function message(e, root) {
|
|
87
|
+
const p = e.params;
|
|
88
|
+
switch (e.keyword) {
|
|
89
|
+
case 'additionalProperties':
|
|
90
|
+
case 'unevaluatedProperties': {
|
|
91
|
+
const name = String(p.additionalProperty ?? p.unevaluatedProperty);
|
|
92
|
+
const known = chain(root, e.parentSchema).flatMap((x) => Object.keys(x.properties ?? {}));
|
|
93
|
+
const near = known.filter((k) => distance(k.toLowerCase(), name.toLowerCase()) <= Math.min(2, Math.max(1, Math.floor(k.length / 3))));
|
|
94
|
+
return `"${name}" is not a key here${near.length > 0 ? `; did you mean ${quoted(near)}?` : ''}`;
|
|
95
|
+
}
|
|
96
|
+
case 'required':
|
|
97
|
+
return `${p.missingProperty.map((k) => JSON.stringify(k)).join(' or ')} is missing`;
|
|
98
|
+
case 'enum': {
|
|
99
|
+
const allowed = p.allowedValues;
|
|
100
|
+
return allowed.length <= 12 ? `must be ${quoted(allowed)}` : `${JSON.stringify(e.data)} is not one of its ${allowed.length} values`;
|
|
101
|
+
}
|
|
102
|
+
case 'const':
|
|
103
|
+
return `must be ${JSON.stringify(p.allowedValue)}`;
|
|
104
|
+
case 'type':
|
|
105
|
+
return `must be ${either(p.type.map((t) => KIND_WORDS[t] ?? t))}`;
|
|
106
|
+
case 'pattern':
|
|
107
|
+
return `${JSON.stringify(e.data)} does not match ${String(p.pattern)}`;
|
|
108
|
+
case 'propertyNames': {
|
|
109
|
+
const said = chain(root, e.parentSchema?.propertyNames).find((x) => typeof x.description === 'string')?.description;
|
|
110
|
+
return `the name "${String(p.propertyName)}" is not allowed${said === undefined ? '' : `: ${said.split('. ')[0].replace(/\.$/, '')}`}`;
|
|
111
|
+
}
|
|
112
|
+
case 'false schema':
|
|
113
|
+
return 'is not allowed here';
|
|
114
|
+
case 'oneOf':
|
|
115
|
+
return 'matches more than one of its forms';
|
|
116
|
+
case 'minItems':
|
|
117
|
+
case 'maxItems':
|
|
118
|
+
case 'minProperties':
|
|
119
|
+
case 'maxProperties': {
|
|
120
|
+
const n = Number(p.limit);
|
|
121
|
+
const what = e.keyword.endsWith('Items') ? (n === 1 ? 'item' : 'items') : n === 1 ? 'key' : 'keys';
|
|
122
|
+
return e.keyword.startsWith('min') ? `needs at least ${n} ${what}` : `has more than ${n} ${what}`;
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
return e.message ?? e.keyword;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Ajv reports every alternative of an anyOf and every rule around a then; keep what the writer can act on
|
|
129
|
+
function simplify(errors) {
|
|
130
|
+
// the reason for an if/then rule, from its $comment, goes onto the errors its then or else gave (just before it)
|
|
131
|
+
errors.forEach((e, i) => {
|
|
132
|
+
const comment = e.parentSchema?.$comment;
|
|
133
|
+
if (e.keyword !== 'if' || typeof comment !== 'string')
|
|
134
|
+
return;
|
|
135
|
+
const base = e.schemaPath.slice(0, -'/if'.length);
|
|
136
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
137
|
+
const x = errors[j];
|
|
138
|
+
if (!within(x.instancePath, e.instancePath) || !(x.schemaPath.startsWith(`${base}/then/`) || x.schemaPath.startsWith(`${base}/else/`)))
|
|
139
|
+
break;
|
|
140
|
+
x.because = comment;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const wrapper = (x) => ['if', 'anyOf', 'oneOf'].includes(x.keyword);
|
|
144
|
+
const explained = (e) => errors.some((x) => x !== e && !wrapper(x) && ((x.instancePath !== e.instancePath && within(x.instancePath, e.instancePath)) || (x.instancePath === e.instancePath && x.keyword !== 'type')));
|
|
145
|
+
const kept = errors.filter((e) => {
|
|
146
|
+
if (e.keyword === 'if')
|
|
147
|
+
return false; // its then or else said it
|
|
148
|
+
if (e.keyword === 'anyOf' || (e.keyword === 'oneOf' && e.params.passingSchemas === null)) {
|
|
149
|
+
return !explained(e) && !errors.some((x) => x !== e && x.keyword === 'type' && x.instancePath === e.instancePath);
|
|
150
|
+
}
|
|
151
|
+
if (e.keyword === 'type')
|
|
152
|
+
return !explained(e); // an alternative of the right type is there, and failed further in
|
|
153
|
+
if (e.propertyName !== undefined)
|
|
154
|
+
return false; // the name's own failure; its propertyNames error says it
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
// what is left at one place in one form is one line: the types the alternatives want, the keys of which one is needed
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const e of kept) {
|
|
160
|
+
const alternative = e.keyword === 'required' && /\/(anyOf|oneOf)\/\d+\/required$/.test(e.schemaPath);
|
|
161
|
+
const twin = out.find((x) => x.instancePath === e.instancePath && x.keyword === e.keyword && (e.keyword === 'type' || (alternative && x.alternative === true)));
|
|
162
|
+
const p = e.params;
|
|
163
|
+
if (twin !== undefined && e.keyword === 'type')
|
|
164
|
+
twin.params.type = [...new Set([...twin.params.type, ...[p.type].flat()])];
|
|
165
|
+
else if (twin !== undefined)
|
|
166
|
+
twin.params.missingProperty.push(p.missingProperty);
|
|
167
|
+
else {
|
|
168
|
+
const params = e.keyword === 'type' ? { type: [p.type].flat() } : e.keyword === 'required' ? { missingProperty: [p.missingProperty] } : e.params;
|
|
169
|
+
out.push({ ...e, alternative, params });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
// the id of the graph a file belongs to, when the file does not say it: the flow, nav or ui graph beside it
|
|
175
|
+
function graphIdBeside(dir) {
|
|
176
|
+
for (const name of ['flow.yaml', 'nav.yaml', 'ui.yaml']) {
|
|
177
|
+
const file = join(dir, name);
|
|
178
|
+
if (!existsSync(file))
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
const id = parse(readFileSync(file, 'utf8'))?.id;
|
|
182
|
+
if (typeof id === 'string')
|
|
183
|
+
return id;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// a graph file that does not parse names no graph; its own check says why
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
// every place where the file does not follow its kind's schema; a file written for another format is left to the
|
|
192
|
+
// format check
|
|
193
|
+
export function validateFile(file) {
|
|
194
|
+
const kind = schemaKindOf(file);
|
|
195
|
+
if (kind === null)
|
|
196
|
+
return [];
|
|
197
|
+
const text = readFileSync(file, 'utf8');
|
|
198
|
+
const lineCounter = new LineCounter();
|
|
199
|
+
const doc = parseDocument(text, { lineCounter, prettyErrors: false });
|
|
200
|
+
const at = (offset) => {
|
|
201
|
+
const { line, col } = lineCounter.linePos(offset ?? 0);
|
|
202
|
+
return { line, column: col };
|
|
203
|
+
};
|
|
204
|
+
const ownFile = kind === 'catalog' ? `catalog/${basename(file)}` : basename(file);
|
|
205
|
+
let graph = null;
|
|
206
|
+
const failure = (where, said, pointer) => {
|
|
207
|
+
graph ??= graphIdBeside(kind === 'catalog' ? dirname(dirname(file)) : dirname(file));
|
|
208
|
+
return { ...where, message: said, graph, element: `${ownFile}#${pointer}` };
|
|
209
|
+
};
|
|
210
|
+
// the first YAML error only: the ones after it mostly follow from it
|
|
211
|
+
if (doc.errors.length > 0)
|
|
212
|
+
return [failure(at(doc.errors[0].pos[0]), `not YAML: ${doc.errors[0].message.split('\n')[0]}`, '')];
|
|
213
|
+
let data;
|
|
214
|
+
if (kind === 'catalog') {
|
|
215
|
+
try {
|
|
216
|
+
data = JSON.parse(text);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return [failure({ line: 1, column: 1 }, `not JSON: ${err.message}`, '')];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
data = doc.toJS();
|
|
224
|
+
const own = data ?? {};
|
|
225
|
+
if (kind !== 'catalog' && typeof own.schema === 'string' && own.schema !== FORMAT)
|
|
226
|
+
return [];
|
|
227
|
+
if (kind !== 'catalog' && typeof own.id === 'string')
|
|
228
|
+
graph = own.id;
|
|
229
|
+
const { roots, validators } = schemas();
|
|
230
|
+
const validate = validators[kind];
|
|
231
|
+
if (validate(data))
|
|
232
|
+
return [];
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const e of simplify((validate.errors ?? []))) {
|
|
235
|
+
const p = e.params;
|
|
236
|
+
const named = p.additionalProperty ?? p.unevaluatedProperty ?? p.propertyName;
|
|
237
|
+
const name = named === undefined ? undefined : String(named);
|
|
238
|
+
const where = place(doc, e.instancePath, name);
|
|
239
|
+
const path = steps(e.instancePath).join('.');
|
|
240
|
+
let said = `${path === '' ? 'the file' : path}: ${message(e, roots[kind])}`;
|
|
241
|
+
const because = e.because ?? (e.keyword === 'false schema' ? undefined : e.parentSchema?.$comment);
|
|
242
|
+
if (typeof because === 'string')
|
|
243
|
+
said += ` (${because})`;
|
|
244
|
+
// `{ title: Done, and paid }` is a title and a key "and paid": YAML ends a flow mapping's value at the comma
|
|
245
|
+
const empty = where.value === null || where.value?.value === null;
|
|
246
|
+
if (name !== undefined && where.map?.flow === true && empty)
|
|
247
|
+
said += '; a comma inside { } ends the value before it: quote that value';
|
|
248
|
+
const missing = e.keyword === 'required' ? p.missingProperty.join('|') : undefined;
|
|
249
|
+
const pointer = name !== undefined ? `${e.instancePath}/${escape(name)}` : missing !== undefined ? `${e.instancePath}/${escape(missing)}` : e.instancePath;
|
|
250
|
+
const range = where.at?.range;
|
|
251
|
+
const finding = failure(at(range?.[0]), said, pointer);
|
|
252
|
+
if (!out.some((x) => x.line === finding.line && x.column === finding.column && x.message === finding.message))
|
|
253
|
+
out.push(finding);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `page --watch` writes the pages again whenever a graph file under the folder changes; with `--serve` it also shows
|
|
2
|
+
// them at http://127.0.0.1:<port>/ and reloads the open page after each write, or shows why the write failed. Local
|
|
3
|
+
// only: the server listens on the loopback address and serves the written pages and nothing else.
|
|
4
|
+
import { existsSync, readFileSync, statSync, watch } from 'node:fs';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { extname, join, normalize, resolve, sep } from 'node:path';
|
|
7
|
+
const WATCHED = new Set(['.yaml', '.yml', '.json']);
|
|
8
|
+
const TYPES = {
|
|
9
|
+
'.html': 'text/html; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.json': 'application/json',
|
|
14
|
+
'.mmd': 'text/plain; charset=utf-8',
|
|
15
|
+
};
|
|
16
|
+
// added to every page the server sends, never to the files on disk
|
|
17
|
+
const RELOAD = `<script>(() => {
|
|
18
|
+
const events = new EventSource('/__delurio/events');
|
|
19
|
+
events.addEventListener('reload', () => location.reload());
|
|
20
|
+
events.addEventListener('failed', (e) => {
|
|
21
|
+
let box = document.getElementById('__delurio');
|
|
22
|
+
if (!box) {
|
|
23
|
+
box = document.createElement('pre');
|
|
24
|
+
box.id = '__delurio';
|
|
25
|
+
box.setAttribute('role', 'alert');
|
|
26
|
+
box.style.cssText = 'position:fixed;inset:auto 0 0 0;margin:0;padding:12px 16px;background:#7f1d1d;color:#fff;font:13px/1.5 ui-monospace,Menlo,Consolas,monospace;white-space:pre-wrap;z-index:2147483647';
|
|
27
|
+
document.body.append(box);
|
|
28
|
+
}
|
|
29
|
+
box.textContent = 'delurio page: ' + e.data;
|
|
30
|
+
});
|
|
31
|
+
})();</script>`;
|
|
32
|
+
// whether a changed file can change a page: a graph file, not one of the pages being written
|
|
33
|
+
function counts(root, out, name) {
|
|
34
|
+
const abs = resolve(root, name);
|
|
35
|
+
if (abs === out || abs.startsWith(out + sep))
|
|
36
|
+
return false;
|
|
37
|
+
if (abs.split(sep).includes('viz'))
|
|
38
|
+
return false;
|
|
39
|
+
return WATCHED.has(extname(abs));
|
|
40
|
+
}
|
|
41
|
+
function serve(out, port, clients) {
|
|
42
|
+
const server = createServer((req, res) => {
|
|
43
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
44
|
+
if (url.pathname === '/__delurio/events') {
|
|
45
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
46
|
+
res.write(': delurio\n\n');
|
|
47
|
+
clients.add(res);
|
|
48
|
+
req.on('close', () => clients.delete(res));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let wanted;
|
|
52
|
+
try {
|
|
53
|
+
wanted = decodeURIComponent(url.pathname);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
res.writeHead(400).end();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let path = join(out, normalize(wanted));
|
|
60
|
+
if (path !== out && !path.startsWith(out + sep)) {
|
|
61
|
+
res.writeHead(403).end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (existsSync(path) && statSync(path).isDirectory())
|
|
65
|
+
path = join(path, 'index.html');
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('not found');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const type = TYPES[extname(path)] ?? 'application/octet-stream';
|
|
71
|
+
let body = readFileSync(path);
|
|
72
|
+
if (type.startsWith('text/html')) {
|
|
73
|
+
const html = body.toString('utf8');
|
|
74
|
+
body = html.includes('</body>') ? html.replace('</body>', `${RELOAD}</body>`) : html + RELOAD;
|
|
75
|
+
}
|
|
76
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' }).end(body);
|
|
77
|
+
});
|
|
78
|
+
server.listen(port, '127.0.0.1', () => console.log(`serving http://127.0.0.1:${port}/ (Ctrl-C stops)`));
|
|
79
|
+
}
|
|
80
|
+
// Runs until stopped. `render` writes every page once and says whether it could.
|
|
81
|
+
export function watchPages(root, out, render, port) {
|
|
82
|
+
const clients = new Set();
|
|
83
|
+
const tell = (event, data) => {
|
|
84
|
+
for (const c of clients)
|
|
85
|
+
c.write(`event: ${event}\ndata: ${data.replace(/\n/g, ' ')}\n\n`);
|
|
86
|
+
};
|
|
87
|
+
if (port !== null)
|
|
88
|
+
serve(out, port, clients);
|
|
89
|
+
let timer = null;
|
|
90
|
+
let running = false;
|
|
91
|
+
let again = false;
|
|
92
|
+
const run = async () => {
|
|
93
|
+
if (running) {
|
|
94
|
+
again = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
running = true;
|
|
98
|
+
do {
|
|
99
|
+
again = false;
|
|
100
|
+
const done = await render();
|
|
101
|
+
tell(done.ok ? 'reload' : 'failed', done.ok ? '' : done.message);
|
|
102
|
+
} while (again);
|
|
103
|
+
running = false;
|
|
104
|
+
};
|
|
105
|
+
watch(root, { recursive: true }, (_event, name) => {
|
|
106
|
+
if (name === null || !counts(resolve(root), out, String(name)))
|
|
107
|
+
return;
|
|
108
|
+
if (timer !== null)
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
timer = setTimeout(() => void run(), 150);
|
|
111
|
+
});
|
|
112
|
+
console.log(`watching ${root} for changes`);
|
|
113
|
+
return new Promise(() => { });
|
|
114
|
+
}
|