@xoong/cli 0.0.0-stage → 0.0.35

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/src/check.js ADDED
@@ -0,0 +1,347 @@
1
+ // @ts-check
2
+ /**
3
+ * `xoong check`: static checks for HTML screens against the synced operations table.
4
+ * Every finding has a stable code, a location and a fix, in the same shape as `xoong doctor`.
5
+ *
6
+ * XO201 unknown operation (with the closest match)
7
+ * XO202 xo-click calls a GET operation (reads belong on <xo-view get>)
8
+ * XO203 <xo-view get> uses an operation that is not GET
9
+ * XO210 <xo-view get> path parameter has no p-<name> attribute
10
+ * XO220 xo-submit is not on a <form>
11
+ * XO222 <form xo-submit> outside an <xo-view> (it never binds)
12
+ * XO221 empty xo-each / xo-if / xo-body expression
13
+ * XO230 unknown xo-* attribute or element (with the closest match)
14
+ * XO240 button/link triggered by xo-click has no accessible name
15
+ * XO241 xo-click on a non-interactive element (not reachable by keyboard)
16
+ * XO250 <xo-table sortable|page-size> but the view's operation lacks the query parameters (sort | limit, offset)
17
+ * XO204 client.load/query on a non-GET operation, or client.mutate/stream on a GET (JavaScript and TypeScript files)
18
+ * XO254 xo-selected not on an xo-click element, or xo-select not on a checkbox
19
+ * XO255 <xo-form op> operation has no request schema (multipart) and required body fields the flat generator cannot draw: the form cannot succeed
20
+ * XO260 route path missing, not starting with "/", or declared twice
21
+ * XO261 layout="x" names a layout the router does not declare
22
+ * XO262 a layout template without an xo-outlet
23
+ * XO263 p-x=":name" outside a route, or naming a parameter the route does not have
24
+ * XO253 <xo-table filters="f"> but the view's operation has no query parameter "f"
25
+ * XO251 <xo-table> outside an <xo-view get>
26
+ * XO252 <xo-form op> operation has no simple request fields to generate a form from
27
+ */
28
+ import { readFile, readdir, stat } from 'node:fs/promises';
29
+ import { join, extname } from 'node:path';
30
+ import { parse } from 'parse5';
31
+ import { nearest } from '@xoong/core/closest';
32
+ import { XO_ATTRIBUTES, XO_ELEMENTS, XO_UI_ATTRIBUTES, XO_UI_ELEMENTS, XO_CONTROL_ATTRIBUTES, XO_CONTROL_ELEMENTS } from '@xoong/core/syntax';
33
+
34
+ const ATTRS = new Set([...XO_ATTRIBUTES, ...XO_UI_ATTRIBUTES, ...XO_CONTROL_ATTRIBUTES]);
35
+ const TAGS = new Set([...XO_ELEMENTS, ...XO_UI_ELEMENTS, ...XO_CONTROL_ELEMENTS]);
36
+ const INTERACTIVE = new Set(['button', 'a', 'input', 'select', 'textarea', 'summary']);
37
+
38
+ /** @typedef {{ level: 'error'|'warn', code: string, file: string, line: number, column: number, message: string, fix: string }} Finding */
39
+
40
+ /** @param {{ operations: Record<string, any> }} table */
41
+ function operationIndex(table) {
42
+ const byRef = new Map();
43
+ for (const op of Object.values(table.operations)) {
44
+ byRef.set(op.id, op);
45
+ byRef.set(`${op.method} ${op.path}`, op);
46
+ }
47
+ return byRef;
48
+ }
49
+
50
+ const attr = (node, name) => node.attrs?.find((a) => a.name === name);
51
+ /** The text a screen reader would announce for an element: its text, plus alt/aria-label of descendants. */
52
+ const text = (node) => (node.childNodes ?? []).map((c) => {
53
+ if (c.nodeName === '#text') return c.value;
54
+ const label = attr(c, 'aria-label')?.value ?? (c.tagName === 'img' ? attr(c, 'alt')?.value : '') ?? '';
55
+ return label.trim() ? label : text(c);
56
+ }).join('').trim();
57
+ const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
58
+
59
+ /**
60
+ * Check one HTML document.
61
+ * @param {string} source
62
+ * @param {{ operations: Record<string, any> }} table
63
+ * @param {string} [file]
64
+ * @returns {Finding[]}
65
+ */
66
+ export function checkHtml(source, table, file = '<input>') {
67
+ /** @type {Finding[]} */
68
+ const findings = [];
69
+ const ops = operationIndex(table);
70
+ const known = [...ops.keys()];
71
+ const document = parse(source, { sourceCodeLocationInfo: true });
72
+
73
+ const report = (level, code, node, name, message, fix) => {
74
+ const loc = (name && node.sourceCodeLocation?.attrs?.[name]) || node.sourceCodeLocation;
75
+ findings.push({ level, code, file, line: loc?.startLine ?? 1, column: loc?.startCol ?? 1, message, fix });
76
+ };
77
+ const lookup = (node, name, ref, what) => {
78
+ const key = ref.trim();
79
+ const op = ops.get(key.startsWith('/') ? `GET ${key}` : key.replace(/\s+/g, ' '));
80
+ if (!op) {
81
+ const hint = nearest(key.startsWith('/') ? `GET ${key}` : key, known);
82
+ report('error', 'XO201', node, name, `unknown operation "${key}"${hint ? ` (closest: "${hint}")` : ''}`, hint ? `Use "${hint}"` : 'Run `xoong sync` if the API changed, or fix the operation');
83
+ }
84
+ return op;
85
+ };
86
+
87
+ /** @param {any} node @param {{ view?: any }} ctx */
88
+ function walk(node, ctx) {
89
+ const children = node.nodeName === 'template' ? node.content?.childNodes ?? [] : node.childNodes ?? [];
90
+ if (node.tagName) {
91
+ const tag = node.tagName;
92
+ let view = ctx.view;
93
+ if (tag.startsWith('xo-') && !TAGS.has(tag)) {
94
+ const hint = nearest(tag, [...TAGS]);
95
+ report('warn', 'XO230', node, undefined, `unknown element <${tag}>${hint ? ` (closest: <${hint}>)` : ''}`, hint ? `Use <${hint}>` : 'Check the spelling or register the element with define()');
96
+ }
97
+ if (tag === 'xo-view') {
98
+ view = node;
99
+ const get = attr(node, 'get');
100
+ if (get) {
101
+ const op = lookup(node, 'get', get.value);
102
+ if (op) {
103
+ if (op.method !== 'GET') report('error', 'XO203', node, 'get', `<xo-view get> uses ${op.method} ${op.path}, which is not a GET operation`, 'Use a GET operation for reading, and xo-click/xo-submit for mutations');
104
+ const provided = new Set(node.attrs.filter((a) => a.name.startsWith('p-')).map((a) => camel(a.name.slice(2))));
105
+ for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
106
+ if (!provided.has(m[1])) report('warn', 'XO210', node, 'get', `path parameter "${m[1]}" of ${op.method} ${op.path} has no p-${m[1]} attribute`, `Add p-${m[1]}="..." (or set view.params at runtime and ignore this)`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ if (tag === 'xo-table') {
112
+ const op = view && attr(view, 'get') ? ops.get((v => (v.startsWith('/') ? `GET ${v}` : v.replace(/\s+/g, ' ')))(attr(view, 'get').value.trim())) : undefined;
113
+ if (!view || !attr(view, 'get')) report('error', 'XO251', node, undefined, '<xo-table> is not inside an <xo-view get="...">', 'Wrap it: <xo-view get="/things"><xo-table ...></xo-table></xo-view>');
114
+ else if (op) {
115
+ const need = [...(attr(node, 'sortable') ? ['sort'] : []), ...(attr(node, 'page-size') ? ['limit', 'offset'] : [])];
116
+ const lacking = need.filter((q) => !(op.query ?? []).includes(q));
117
+ for (const f of (attr(node, 'filters')?.value ?? '').split(',').map((x) => x.trim().split(':')[0]).filter(Boolean)) { // "q:Search": the part before the colon is the query parameter
118
+ if (!(op.query ?? []).includes(f)) report('error', 'XO253', node, 'filters', `${op.method} ${op.path} has no query parameter "${f}", which the filter box for "${f}" sets`, `Add "${f}" to the operation's parameters, or remove it from filters`);
119
+ }
120
+ if (lacking.length) report('error', 'XO250', node, attr(node, 'sortable') && lacking.includes('sort') ? 'sortable' : 'page-size', `${op.method} ${op.path} has no query parameter ${lacking.map((q) => `"${q}"`).join(', ')}, which <xo-table> needs for ${attr(node, 'sortable') ? 'sorting' : 'paging'}`, `Add ${lacking.join(', ')} to the operation's parameters, or remove ${lacking.includes('sort') ? 'sortable' : 'page-size'}`);
121
+ }
122
+ }
123
+ if (tag === 'xo-form') {
124
+ const ref = attr(node, 'op');
125
+ const op = ref ? lookup(node, 'op', ref.value) : undefined;
126
+ const missing = op?.bodySchema ? [] : (op?.bodySkipped ?? []).filter((s) => s.required); // with a request schema the generator draws nested fields too
127
+ if (op && missing.length) report('warn', 'XO255', node, 'op', `${op.method} ${op.path} requires ${missing.map((m) => `${m.name} (${m.type})`).join(', ')}, which <xo-form> cannot generate yet, so the generated form can never succeed`, 'Write this form by hand (<form xo-submit>), or make the field optional in the API');
128
+ if (op && !(op.bodyFields ?? []).length) report('warn', 'XO252', node, 'op', `${op.method} ${op.path} has no simple request fields, so <xo-form> would be empty`, 'Write the <form xo-submit> by hand, or describe the request body as an object of strings, numbers and booleans');
129
+ }
130
+ for (const a of node.attrs ?? []) {
131
+ if (a.name.startsWith('xo-') && !ATTRS.has(a.name)) {
132
+ const hint = nearest(a.name, [...ATTRS]);
133
+ report('error', 'XO230', node, a.name, `unknown attribute ${a.name}${hint ? ` (closest: ${hint})` : ''}`, hint ? `Use ${hint}` : 'Remove it; xoong has no such attribute');
134
+ }
135
+ if (a.name === 'xo-selected' && !attr(node, 'xo-click')) report('error', 'XO254', node, a.name, 'xo-selected needs xo-click on the same element', 'Add xo-click="METHOD /path/{id}" to run once per ticked row');
136
+ if (a.name === 'xo-select' && (tag !== 'input' || attr(node, 'type')?.value !== 'checkbox')) report('error', 'XO254', node, a.name, `xo-select must be on <input type="checkbox">, found <${tag}>`, 'Use a checkbox, or <xo-table selectable>');
137
+ if ((a.name === 'xo-each' || a.name === 'xo-if' || a.name === 'xo-body') && !a.value.trim()) {
138
+ report('error', 'XO221', node, a.name, `${a.name} is empty`, `Give ${a.name} a path, for example ${a.name}="items"`);
139
+ }
140
+ if (a.name === 'xo-click' || a.name === 'xo-submit') {
141
+ const op = lookup(node, a.name, a.value);
142
+ if (op && op.method === 'GET' && a.name === 'xo-click') report('warn', 'XO202', node, a.name, `${a.name} calls the GET operation ${op.path}`, 'Reads are declared with <xo-view get="...">; xo-click is for mutations');
143
+ if (a.name === 'xo-submit' && tag === 'form' && !view) report('error', 'XO222', node, a.name, '<form xo-submit> is not inside an <xo-view>, so it never binds: no model, the inputs stay empty', 'Wrap it in <xo-view> (an <xo-view> without get is enough)');
144
+ if (a.name === 'xo-submit' && tag !== 'form') report('error', 'XO220', node, a.name, `xo-submit must be on a <form>, found <${tag}>`, 'Move xo-submit to the form element');
145
+ if (a.name === 'xo-click') {
146
+ if (!INTERACTIVE.has(tag) && !attr(node, 'role') && !attr(node, 'tabindex')) {
147
+ report('warn', 'XO241', node, a.name, `xo-click on <${tag}> is not reachable by keyboard`, 'Use a <button>, or add role="button" and tabindex="0"');
148
+ }
149
+ if ((tag === 'button' || tag === 'a') && !text(node) && !attr(node, 'aria-label') && !attr(node, 'aria-labelledby') && !attr(node, 'title')) {
150
+ report('warn', 'XO240', node, a.name, `<${tag}> has no accessible name`, 'Add visible text or aria-label');
151
+ }
152
+ }
153
+ }
154
+ }
155
+ // --- <xo-router>: routes, layouts, and route parameters used by views
156
+ let router = ctx.router;
157
+ let route = ctx.route;
158
+ if (tag === 'xo-router') {
159
+ const templates = (node.childNodes ?? []).filter((c) => c.tagName === 'template');
160
+ const layouts = new Set(templates.map((t) => attr(t, 'xo-layout')?.value).filter(Boolean));
161
+ router = { layouts };
162
+ const seen = new Set();
163
+ const hasOutlet = (n) => (n.childNodes ?? []).some((c) => attr(c, 'xo-outlet') || hasOutlet(c)) || (n.content?.childNodes ?? []).some((c) => attr(c, 'xo-outlet') || hasOutlet(c));
164
+ for (const t of templates) {
165
+ const path = attr(t, 'xo-route');
166
+ const layoutName = attr(t, 'xo-layout');
167
+ if (path) {
168
+ const p = path.value.trim();
169
+ if (!p || (p !== '*' && !p.startsWith('/'))) report('error', 'XO260', t, 'xo-route', `route path "${p}" must start with "/" (or be "*" for not found)`, 'Write xo-route="/users/:id"');
170
+ else if (seen.has(p)) report('error', 'XO260', t, 'xo-route', `route "${p}" is declared twice`, 'Remove the duplicate; the first one wins at runtime');
171
+ seen.add(p);
172
+ }
173
+ const uses = attr(t, 'layout');
174
+ if (uses && !layouts.has(uses.value)) {
175
+ const hint = nearest(uses.value, [...layouts]);
176
+ report('error', 'XO261', t, 'layout', `layout "${uses.value}" is not declared${hint ? ` (closest: "${hint}")` : ''}`, hint ? `Use layout="${hint}"` : `Add <template xo-layout="${uses.value}"> with an element that has xo-outlet`);
177
+ }
178
+ if (layoutName && !hasOutlet(t)) report('error', 'XO262', t, 'xo-layout', `layout "${layoutName.value}" has no element with xo-outlet`, 'Put xo-outlet on the element where the route content should appear, for example <main xo-outlet>');
179
+ }
180
+ }
181
+ if (tag === 'template' && attr(node, 'xo-route') && router) route = attr(node, 'xo-route').value.trim();
182
+ for (const a of node.attrs ?? []) {
183
+ if (a.name.startsWith('p-') && a.value.startsWith(':')) {
184
+ const name = a.value.slice(1);
185
+ const names = route ? [...route.matchAll(/:([\w-]+)/g)].map((m) => m[1]) : null;
186
+ if (!names) report('error', 'XO263', node, a.name, `${a.name}="${a.value}" reads a route parameter, but this is not inside a <template xo-route>`, 'Use it inside a route template, or write a fixed value or p-x="?query"');
187
+ else if (!names.includes(name)) {
188
+ const hint = nearest(name, names);
189
+ report('error', 'XO263', node, a.name, `route "${route}" has no parameter ":${name}"${hint ? ` (closest: ":${hint}")` : ''}`, hint ? `Use ${a.name}=":${hint}"` : `Add :${name} to the route path`);
190
+ }
191
+ }
192
+ }
193
+ ctx = { view, router, route };
194
+ }
195
+ for (const child of children) walk(child, ctx);
196
+ }
197
+ walk(document, {});
198
+ findings.sort((a, b) => a.line - b.line || a.column - b.column);
199
+ return findings;
200
+ }
201
+
202
+ /**
203
+ * Template literals tagged `html` in a script: [{ text, line, column }]. `text` has the same shape as the source (same line
204
+ * breaks, same widths): each `${...}` hole becomes "x" followed by spaces and keeps its newlines, so line and column of any
205
+ * finding inside the template map straight back to the file. `line`/`column` point at the first character of the template.
206
+ *
207
+ * A small scanner, not a parser: it skips comments, strings, nested templates and regex literals, so `html\`` inside a comment or
208
+ * string is not a template and braces inside strings do not end a hole. Anything it cannot read is skipped, never guessed.
209
+ */
210
+ export function htmlTemplates(source) {
211
+ const out = [];
212
+ const n = source.length;
213
+ let i = 0;
214
+ let prevSignificant = ''; // last non-space code character: tells a regex literal from a division
215
+
216
+ /** Skip a quoted string starting at i (the quote); returns the index after it. */
217
+ const skipString = (q) => { for (i++; i < n && source[i] !== q && source[i] !== '\n'; i++) if (source[i] === '\\') i++; return i + 1; };
218
+ /** Skip a template literal (any tag) starting at i (the backtick), including its holes; returns the index after it. */
219
+ const skipTemplate = () => {
220
+ for (i++; i < n && source[i] !== '`'; i++) {
221
+ if (source[i] === '\\') i++;
222
+ else if (source[i] === '$' && source[i + 1] === '{') { i += 2; skipCode('}'); i--; }
223
+ }
224
+ return i + 1;
225
+ };
226
+ /** Skip code until the closing brace of a hole (depth 0), starting after the opening one. Leaves i after the brace. */
227
+ const skipCode = (until) => {
228
+ let depth = 0;
229
+ while (i < n) {
230
+ const c = source[i];
231
+ if (c === '"' || c === "'") { i = skipString(c); continue; }
232
+ if (c === '`') { i = skipTemplate(); continue; }
233
+ if (c === '/' && source[i + 1] === '/') { while (i < n && source[i] !== '\n') i++; continue; }
234
+ if (c === '/' && source[i + 1] === '*') { const e = source.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
235
+ if (c === '{') depth++;
236
+ else if (c === '}') { if (depth === 0 && until === '}') { i++; return; } depth--; }
237
+ i++;
238
+ }
239
+ };
240
+
241
+ while (i < n) {
242
+ const c = source[i];
243
+ if (c === '"' || c === "'") { i = skipString(c); prevSignificant = c; continue; }
244
+ if (c === '/' && source[i + 1] === '/') { while (i < n && source[i] !== '\n') i++; continue; }
245
+ if (c === '/' && source[i + 1] === '*') { const e = source.indexOf('*/', i + 2); i = e < 0 ? n : e + 2; continue; }
246
+ if (c === '/' && /[(,=:[!&|?{};]|^$/.test(prevSignificant)) { // regex literal
247
+ let inClass = false;
248
+ for (i++; i < n && source[i] !== '\n'; i++) {
249
+ if (source[i] === '\\') i++; else if (source[i] === '[') inClass = true; else if (source[i] === ']') inClass = false; else if (source[i] === '/' && !inClass) break;
250
+ }
251
+ i++; prevSignificant = '/'; continue;
252
+ }
253
+ if (c === '`') {
254
+ const tagged = /(?:^|[^\w$.])html\s*$/.test(source.slice(Math.max(0, i - 40), i));
255
+ if (!tagged) { i = skipTemplate(); prevSignificant = '`'; continue; }
256
+ const start = i + 1;
257
+ let code = '';
258
+ for (i++; i < n && source[i] !== '`'; i++) {
259
+ if (source[i] === '\\') { code += source.slice(i, i + 2); i++; continue; }
260
+ if (source[i] === '$' && source[i + 1] === '{') {
261
+ const from = i;
262
+ i += 2; skipCode('}'); i--; // i is now on the closing brace
263
+ const hole = source.slice(from, i + 1);
264
+ code += 'x' + hole.slice(1).replace(/[^\n]/g, ' ');
265
+ continue;
266
+ }
267
+ code += source[i];
268
+ }
269
+ const before = source.slice(0, start).split('\n');
270
+ out.push({ text: code, line: before.length, column: before[before.length - 1].length + 1 });
271
+ i++; prevSignificant = '`'; continue;
272
+ }
273
+ if (!/\s/.test(c)) prevSignificant = c;
274
+ i++;
275
+ }
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * Check a JavaScript or TypeScript file: HTML inside html`...` templates, and the first argument of
281
+ * client.load / query / mutate / stream when it is a string literal.
282
+ * @param {string} source
283
+ * @param {{ operations: Record<string, any> }} table
284
+ * @param {string} [file]
285
+ * @returns {Finding[]}
286
+ */
287
+ export function checkScript(source, table, file = '<input>') {
288
+ /** @type {Finding[]} */
289
+ const findings = [];
290
+ for (const t of htmlTemplates(source)) {
291
+ for (const f of checkHtml(t.text, table, file)) {
292
+ findings.push({ ...f, line: t.line + f.line - 1, column: f.line === 1 ? t.column + f.column - 1 : f.column }); // text has the source's shape
293
+ }
294
+ }
295
+ const ops = operationIndex(table);
296
+ const known = [...ops.keys()];
297
+ const lineCol = (index) => { const lines = source.slice(0, index).split('\n'); return { line: lines.length, column: lines[lines.length - 1].length + 1 }; };
298
+ // Only calls on something that looks like a xoong client: `client`, `xoongClient`, `getClient()`, `xo` (so `db.query('SELECT 1')` is left alone).
299
+ for (const m of source.matchAll(/(?:\b\w*[cC]lient|\bxo|\bgetClient\(\))\s*\.(load|query|mutate|stream)\(\s*(['"])([^'"\n]+)\2/g)) {
300
+ const [, method, , ref] = m;
301
+ const key = ref.startsWith('/') ? `GET ${ref}` : ref.replace(/\s+/g, ' ').trim();
302
+ const at = lineCol(m.index + m[0].indexOf(ref));
303
+ const op = ops.get(key);
304
+ if (!op) {
305
+ const hint = nearest(key, known);
306
+ findings.push({ level: 'error', code: 'XO201', file, ...at, message: `unknown operation "${ref}"${hint ? ` (closest: "${hint}")` : ''}`, fix: hint ? `Use "${hint}"` : 'Run `xoong sync` if the API changed, or fix the operation' });
307
+ } else if ((method === 'load' || method === 'query') && op.method !== 'GET') {
308
+ findings.push({ level: 'error', code: 'XO204', file, ...at, message: `client.${method}() reads, but ${op.method} ${op.path} is not a GET operation`, fix: 'Use client.mutate() for writes' });
309
+ } else if (method === 'mutate' && op.method === 'GET') {
310
+ findings.push({ level: 'error', code: 'XO204', file, ...at, message: `client.mutate() writes, but ${op.path} is a GET operation`, fix: 'Use client.load() or client.query() for reads' });
311
+ }
312
+ }
313
+ findings.sort((a, b) => a.line - b.line || a.column - b.column);
314
+ return findings;
315
+ }
316
+
317
+ /** Collect .html files from files and folders (skips node_modules and dot folders). */
318
+ export async function collectHtml(paths) {
319
+ const out = [];
320
+ const visit = async (p) => {
321
+ const s = await stat(p);
322
+ if (s.isDirectory()) {
323
+ for (const name of await readdir(p)) if (!['node_modules', 'dist', 'build', 'coverage'].includes(name) && !name.startsWith('.')) await visit(join(p, name));
324
+ } else if (['.html', '.htm', '.js', '.mjs', '.ts', '.mts'].includes(extname(p).toLowerCase()) && !p.endsWith('.d.ts') && !p.endsWith('.min.js')) out.push(p);
325
+ };
326
+ for (const p of paths) await visit(p);
327
+ return out.sort();
328
+ }
329
+
330
+ /** @returns {Promise<{ findings: Finding[], files: number }>} */
331
+ export async function checkFiles(paths, table) {
332
+ const files = await collectHtml(paths);
333
+ const findings = [];
334
+ for (const file of files) {
335
+ const source = await readFile(file, 'utf8');
336
+ findings.push(...(/\.html?$/i.test(file) ? checkHtml(source, table, file) : checkScript(source, table, file)));
337
+ }
338
+ return { findings, files: files.length };
339
+ }
340
+
341
+ /** Human-readable report. */
342
+ export function formatFindings(findings, { files = 0 } = {}) {
343
+ const lines = findings.map((f) => `${f.file}:${f.line}:${f.column} ${f.level} ${f.code} ${f.message}\n fix: ${f.fix}`);
344
+ const errors = findings.filter((f) => f.level === 'error').length;
345
+ lines.push(`\n${files} files, ${errors} errors, ${findings.length - errors} warnings`);
346
+ return lines.join('\n');
347
+ }
package/src/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { sync, doctor } from './commands.js';
4
+ import { startDev, parseProxyFlags } from './dev.js';
5
+ import { checkFiles, formatFindings } from './check.js';
6
+ import { scaffold } from './scaffold.js';
7
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
8
+ import { dirname } from 'node:path';
9
+
10
+ const HELP = `xoong <command>
11
+
12
+ sync <openapi.json|yaml|url> [--out .xoong] [--hints hints.json] generate operations.json and api.d.ts
13
+ doctor <openapi.json|yaml|url> [--hints hints.json] [--all] audit the OpenAPI document for xoong
14
+ new <folder> --spec <openapi.json|yaml|url> [--title "My app"] [--link <xoong checkout>] create a project with a first screen
15
+ check <file|folder>... [--operations .xoong/operations.json] [--format text|json] check HTML screens
16
+ render <page.html> --api <url> [--url /users/7] [--operations .xoong/operations.json] [--out file] prerender a page (a route, with --url) with its data (needs @xoong/ssr)
17
+ build [--root .] [--out dist] [--base /admin] [--include path]... static output for strict-CSP hosting (no import map, no inline code)
18
+ i18n extract <html|js files or folders>... [--out catalogs/en.json] list xo-t keys with their source text
19
+ i18n check <catalog.json> --source <en.json> missing keys, wrong placeholders, invalid messages
20
+ dev [--root .] [--port 5173] [--proxy /api=http://localhost:8080] [--spa] serve the app (static, proxy, .ts)
21
+ `;
22
+
23
+ const [cmd, ...rest] = process.argv.slice(2);
24
+ if (rest.includes('--help') || rest.includes('-h') || cmd === '--help' || cmd === '-h' || cmd === 'help') { console.log(HELP); process.exit(0); } // never start work on a help request
25
+ const flag = (name, fallback) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : fallback; };
26
+
27
+ try {
28
+ if (cmd === 'sync' && rest[0]) {
29
+ const a = await sync(rest[0], { out: flag('--out', '.xoong'), hints: flag('--hints') });
30
+ console.log(`synced ${Object.keys(a.operations).length} operations, ${Object.keys(a.entities).length} entities`);
31
+ const errors = a.problems.filter((p) => p.level === 'error');
32
+ for (const p of errors) console.error(`error ${p.code} ${p.where}\n ${p.message}\n fix: ${p.fix}`);
33
+ if (errors.length) process.exitCode = 1;
34
+ const warnings = a.problems.length - errors.length;
35
+ if (warnings) console.log(`${warnings} warnings: run "xoong doctor ${rest[0]}"`);
36
+ } else if (cmd === 'doctor' && rest[0]) {
37
+ const { text, errors } = await doctor(rest[0], { hints: flag('--hints'), all: rest.includes('--all') });
38
+ console.log(text);
39
+ process.exitCode = errors ? 1 : 0;
40
+ } else if (cmd === 'new' && rest[0] && flag('--spec')) {
41
+ const result = await scaffold(rest[0], { spec: flag('--spec'), title: flag('--title'), link: flag('--link') });
42
+ console.log(`created ${result.dir}\n first screen: ${result.screen ?? 'none (no list operation found)'}\n next: cd ${rest[0]} && npm install && npm run dev\n ${flag('--link') ? '(packages linked from ' + flag('--link') + ')' : '(the @xoong/* packages are not published yet: run again with --link <path to the xoong repository>)'}`);
43
+ } else if (cmd === 'check' && rest.length) {
44
+ const opsFile = flag('--operations', '.xoong/operations.json');
45
+ let table;
46
+ try { table = JSON.parse(await readFile(opsFile, 'utf8')); } catch { throw new Error(`xoong: cannot read ${opsFile}. Run \`xoong sync <openapi>\` first, or pass --operations <file>`); }
47
+ const format = flag('--format', 'text');
48
+ const targets = rest.filter((v, i) => !v.startsWith('--') && !['--operations', '--format'].includes(rest[i - 1]));
49
+ const { findings, files } = await checkFiles(targets, table);
50
+ console.log(format === 'json' ? JSON.stringify(findings, null, 2) : formatFindings(findings, { files }));
51
+ process.exitCode = findings.some((f) => f.level === 'error') ? 1 : 0;
52
+ } else if (cmd === 'render' && rest[0] && flag('--api')) {
53
+ let renderPage;
54
+ try { ({ renderPage } = await import('@xoong/ssr')); } catch { throw new Error('xoong: `render` needs the @xoong/ssr package: npm install @xoong/ssr'); }
55
+ const opsFile = flag('--operations', '.xoong/operations.json');
56
+ let operations;
57
+ try { operations = JSON.parse(await readFile(opsFile, 'utf8')); } catch { throw new Error(`xoong: cannot read ${opsFile}. Run \`xoong sync <openapi>\` first, or pass --operations <file>`); }
58
+ const at = flag('--url');
59
+ const html = await renderPage(await readFile(rest[0], 'utf8'), { operations, baseUrl: flag('--api'), ...(at ? { url: new URL(at, 'http://xoong.local').href } : {}) });
60
+ const out = flag('--out');
61
+ if (out) { await mkdir(dirname(out), { recursive: true }); await writeFile(out, html); console.log(`rendered ${rest[0]} -> ${out}`); } else process.stdout.write(html);
62
+ } else if (cmd === 'i18n' && rest[0] === 'extract' && rest[1]) {
63
+ const { extractFrom, toCatalog } = await import('./i18n-extract.js');
64
+ const out = flag('--out');
65
+ const paths = rest.slice(1).filter((v, i, a) => !v.startsWith('--') && a[i - 1] !== '--out');
66
+ const { catalog, problems } = toCatalog(await extractFrom(paths));
67
+ const json = JSON.stringify(catalog, null, 2) + '\n';
68
+ if (out) { await mkdir(dirname(out), { recursive: true }); await writeFile(out, json); console.log(`${Object.keys(catalog).length} messages -> ${out}`); } else process.stdout.write(json);
69
+ for (const p of problems) console.error(`error: ${p}`);
70
+ process.exitCode = problems.length ? 1 : 0;
71
+ } else if (cmd === 'i18n' && rest[0] === 'check' && rest[1] && flag('--source')) {
72
+ const { checkCatalog } = await import('./i18n-extract.js');
73
+ const problems = checkCatalog(JSON.parse(await readFile(flag('--source'), 'utf8')), JSON.parse(await readFile(rest[1], 'utf8')), rest[1]);
74
+ for (const p of problems) console.error(p);
75
+ console.log(problems.length ? `${problems.length} problems` : 'ok');
76
+ process.exitCode = problems.length ? 1 : 0;
77
+ } else if (cmd === 'build') {
78
+ const unknown = rest.filter((v, i) => v.startsWith('-') && !['--root', '--out', '--base', '--include'].includes(v) && true);
79
+ if (unknown.length) throw new Error(`xoong build: unknown option ${unknown.join(', ')} (known: --root, --out, --base, --include)`);
80
+ const { build, CSP } = await import('./build.js');
81
+ const include = rest.flatMap((v, i) => (rest[i - 1] === '--include' ? [v] : []));
82
+ const r = await build({ root: flag('--root', '.'), out: flag('--out', 'dist'), base: flag('--base', ''), include });
83
+ const total = r.files.reduce((n, f) => n + f.bytes, 0);
84
+ const gz = r.files.reduce((n, f) => n + f.gzip, 0);
85
+ console.log(`built ${r.files.length} files into ${r.out} (${(total / 1024).toFixed(1)} KB, ${(gz / 1024).toFixed(1)} KB gzip)`);
86
+ for (const w of r.warnings) console.warn(`warning: ${w}`);
87
+ console.log(`\nruns under: Content-Security-Policy: ${CSP}`);
88
+ process.exitCode = r.warnings.length ? 0 : 0;
89
+ } else if (cmd === 'dev') {
90
+ const proxies = rest.flatMap((v, i) => (rest[i - 1] === '--proxy' ? [v] : []));
91
+ const port = flag('--port');
92
+ await startDev({ root: flag('--root'), port: port ? Number(port) : undefined, proxy: parseProxyFlags(proxies), spa: rest.includes('--spa') || undefined });
93
+ // keep running until interrupted
94
+ } else {
95
+ console.log(HELP);
96
+ process.exitCode = cmd ? 2 : 0;
97
+ }
98
+ } catch (e) {
99
+ console.error(e instanceof Error ? e.message : e);
100
+ process.exitCode = 1;
101
+ }
@@ -0,0 +1,55 @@
1
+ // @ts-check
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { analyze, generateDts, runtimeTable } from './openapi.js';
6
+ import { htmlData } from './html-data.js';
7
+
8
+ /** Read an OpenAPI document from a file path or an http(s) URL. */
9
+ export async function load(source) {
10
+ let text;
11
+ if (/^https?:\/\//.test(source)) {
12
+ const res = await fetch(source);
13
+ if (!res.ok) throw new Error(`xoong: could not download ${source} (HTTP ${res.status})`);
14
+ text = await res.text();
15
+ } else {
16
+ text = await readFile(source, 'utf8');
17
+ }
18
+ if (/\.ya?ml(\?|$)/i.test(source)) return parseYaml(text);
19
+ try { return JSON.parse(text); } catch (jsonError) {
20
+ try { return parseYaml(text); } catch { throw new Error(`xoong: could not parse ${source} as JSON or YAML (${jsonError.message})`); }
21
+ }
22
+ }
23
+
24
+ /** @returns the analysis and writes .xoong/operations.json + .xoong/api.d.ts */
25
+ export async function sync(source, { out = '.xoong', hints } = {}) {
26
+ const a = analyze(await load(source), { hints: hints ? JSON.parse(await readFile(hints, 'utf8')) : undefined });
27
+ await mkdir(out, { recursive: true });
28
+ const table = runtimeTable(a);
29
+ await writeFile(join(out, 'operations.json'), JSON.stringify(table, null, 2) + '\n');
30
+ await writeFile(join(out, 'xoong.html-data.json'), JSON.stringify(htmlData(table), null, 2) + '\n'); // editor completion
31
+ await writeFile(join(out, 'api.d.ts'), generateDts(a));
32
+ return a;
33
+ }
34
+
35
+ /**
36
+ * Format findings as `xoong doctor` output. Errors always print in full; each warning code shows
37
+ * up to `limit` examples (use `all` to print everything) so a large API stays readable.
38
+ * @returns {Promise<{ text: string, errors: number, problems: any[] }>}
39
+ */
40
+ export async function doctor(source, { hints, all = false, limit = 5 } = {}) {
41
+ const { problems, operations, entities } = analyze(await load(source), { hints: hints ? JSON.parse(await readFile(hints, 'utf8')) : undefined });
42
+ const shown = new Map();
43
+ const hidden = new Map();
44
+ const lines = [];
45
+ for (const p of problems) {
46
+ const n = shown.get(p.code) ?? 0;
47
+ if (p.level === 'warn' && !all && n >= limit) { hidden.set(p.code, (hidden.get(p.code) ?? 0) + 1); continue; }
48
+ shown.set(p.code, n + 1);
49
+ lines.push(`${p.level} ${p.code} ${p.where}\n ${p.message}\n fix: ${p.fix}`);
50
+ }
51
+ for (const [code, n] of hidden) lines.push(`... and ${n} more ${code} (run with --all to list them)`);
52
+ const errors = problems.filter((p) => p.level === 'error').length;
53
+ lines.push(`\n${Object.keys(operations).length} operations, ${Object.keys(entities).length} entities, ${errors} errors, ${problems.length - errors} warnings`);
54
+ return { text: lines.join('\n'), errors, problems };
55
+ }
package/src/dev.js ADDED
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ /**
6
+ * Read `xoong.config.json` from `dir` (all fields optional):
7
+ * { "root": ".", "port": 5173, "proxy": { "/api": "http://localhost:8080" }, "spa": false, "allowedHosts": [], "packages": ["rainlet"] }
8
+ * `packages`: installed packages (besides xoong's own) whose browser code the app imports; served at /@name and listed in the import map.
9
+ */
10
+ export async function loadConfig(dir = '.') {
11
+ try { return JSON.parse(await readFile(join(dir, 'xoong.config.json'), 'utf8')); } catch (error) {
12
+ if (/** @type {any} */ (error)?.code === 'ENOENT') return {};
13
+ throw new Error(`xoong: could not read xoong.config.json: ${/** @type {Error} */ (error).message}`);
14
+ }
15
+ }
16
+
17
+ /** Parse repeated `--proxy /api=http://host:port` flags into a map. */
18
+ export function parseProxyFlags(values) {
19
+ /** @type {Record<string, string>} */
20
+ const proxy = {};
21
+ for (const v of values) {
22
+ const i = v.indexOf('=');
23
+ if (i <= 0) throw new Error(`xoong: --proxy expects /prefix=http://target, got "${v}"`);
24
+ proxy[v.slice(0, i)] = v.slice(i + 1);
25
+ }
26
+ return proxy;
27
+ }
28
+
29
+ /**
30
+ * Start the dev server.
31
+ * @param {{ root?: string, port?: number, host?: string, allowedHosts?: string[], proxy?: Record<string,string>, spa?: boolean, resolveFrom?: string, quiet?: boolean, packages?: string[] }} [options]
32
+ */
33
+ export async function startDev(options = {}) {
34
+ const config = await loadConfig(options.root ?? '.');
35
+ const root = resolve(options.root ?? config.root ?? '.');
36
+ const proxy = { ...(config.proxy ?? {}), ...(options.proxy ?? {}) };
37
+ const { createDevHandler, serve } = await import('@xoong/server/node');
38
+ const handler = await createDevHandler({
39
+ root, resolveFrom: options.resolveFrom ?? root, proxy, packages: options.packages ?? config.packages ?? [], spa: options.spa ?? config.spa ?? false,
40
+ onError: (error, request) => console.error(`xoong dev: ${request.method} ${new URL(request.url).pathname}: ${/** @type {Error} */ (error)?.message ?? error}`),
41
+ });
42
+ const running = await serve(handler, { port: options.port ?? config.port ?? 5173, host: options.host ?? '127.0.0.1', allowedHosts: options.allowedHosts ?? config.allowedHosts ?? [] });
43
+ if (!options.quiet) {
44
+ console.log(`xoong dev ${running.url} (root: ${root})`);
45
+ for (const [prefix, target] of Object.entries(proxy)) console.log(` proxy ${prefix} -> ${target}`);
46
+ }
47
+ return running;
48
+ }