@delurio/cli 0.1.0 → 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 +48 -7
- package/SPEC.md +9 -7
- package/dist/check.js +26 -2
- package/dist/cli.js +98 -18
- package/dist/index-page.js +50 -0
- 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/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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delurio/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The open checker and page of delurio-graph/0: check every graph in a project's own CI, offline, with GNU error lines and SARIF.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": { "delurio": "dist/cli.js" },
|
|
8
|
-
"files": ["dist", "SPEC.md", "NOTICE"],
|
|
8
|
+
"files": ["dist", "schemas", "SPEC.md", "NOTICE", "CHANGELOG.md"],
|
|
9
9
|
"engines": { "node": ">=22.18" },
|
|
10
10
|
"repository": { "type": "git", "url": "git+https://github.com/delurio-labs/delurio.git" },
|
|
11
11
|
"homepage": "https://delur.io",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"test": "node src/cli.ts check examples --strict"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"ajv": "8.20.0",
|
|
21
|
+
"ajv-formats": "3.0.1",
|
|
20
22
|
"elkjs": "0.12.0",
|
|
21
23
|
"yaml": "2.9.1"
|
|
22
24
|
},
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://unpkg.com/@delurio/cli@0.2.0/schemas/catalog.schema.json",
|
|
4
|
+
"title": "delurio catalog (catalog/<locale>.json)",
|
|
5
|
+
"description": "The words of a UI graph in one locale (SPEC.md §4): a JSON object from key to an ICU MessageFormat message. The file is named by its BCP 47 locale (en.json, tr.json, pt-BR.json). A captured catalog is copied from the app as it is, so its keys may be in any form.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"propertyNames": { "minLength": 1 },
|
|
8
|
+
"additionalProperties": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "An ICU MessageFormat message. Today the tools fill simple arguments: `{param}` is filled from the node's `params`, which name sample or data values.",
|
|
11
|
+
"examples": ["{client} wants access to your account"]
|
|
12
|
+
}
|
|
13
|
+
}
|