@xoong/cli 0.0.0-stage → 0.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -2
- package/package.json +47 -4
- package/src/body-schema.js +161 -0
- package/src/build.js +211 -0
- package/src/check.js +347 -0
- package/src/cli.js +101 -0
- package/src/commands.js +55 -0
- package/src/dev.js +48 -0
- package/src/html-data.js +85 -0
- package/src/i18n-extract.js +78 -0
- package/src/index.js +2 -0
- package/src/openapi.js +474 -0
- package/src/scaffold.js +159 -0
- package/src/swagger2.js +88 -0
package/src/html-data.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `xoong.html-data.json`: custom data for VS Code's HTML language service (`html.customData`).
|
|
4
|
+
* Gives completion and hover for xo-* elements and attributes, and completes operation names
|
|
5
|
+
* for xo-click, xo-submit, <xo-view get> and <xo-form op> from the synced operations table.
|
|
6
|
+
* Format: https://github.com/microsoft/vscode-html-languageservice/blob/main/docs/customData.md
|
|
7
|
+
*/
|
|
8
|
+
import { XO_ATTRIBUTES, XO_ELEMENTS, XO_UI_ATTRIBUTES, XO_UI_ELEMENTS, XO_CONTROL_ATTRIBUTES, XO_CONTROL_ELEMENTS } from '@xoong/core/syntax';
|
|
9
|
+
|
|
10
|
+
export const ATTRIBUTE_DOCS = {
|
|
11
|
+
'xo-each': 'Repeat this element for each item of the list at the given path (keyed by entity id).',
|
|
12
|
+
'xo-if': 'Render this element only when the path is truthy. Prefix with `!` to negate. An empty array is false.',
|
|
13
|
+
'xo-click': 'Call an operation when clicked: "METHOD /path/{param}" or an operationId. Params come from the current item, then the view\'s p-* params.',
|
|
14
|
+
'xo-submit': 'On a <form>: send the form fields as the request body of the operation. The form is reset on success unless xo-keep is present. Field errors from the server appear beside the inputs.',
|
|
15
|
+
'xo-body': 'With xo-click: take the request body from the item at this path.',
|
|
16
|
+
'xo-keep': 'With xo-submit: keep the field values after a successful send.',
|
|
17
|
+
'xo-t': 'Translate this element with @xoong/i18n: `xo-t="key"` for its text, `xo-t="attr:key; attr2:key2"` for attributes. The text written in the HTML is the default. `{placeholders}` come from the enclosing xo-view.',
|
|
18
|
+
'xo-add': 'In a form: append an item to the array at this path (`blocks`); `xo-with="type=image"` gives a union item its tag.',
|
|
19
|
+
'xo-remove': 'In a form row: remove the array item at this path (`blocks[{$index}]`).',
|
|
20
|
+
'xo-move': 'In a form row: move the array item at this path, `blocks[{$index}]:up` or `:down`.',
|
|
21
|
+
'xo-drag': 'On a handle inside an array row of a form: drag the row onto another row of the same list to move it (`blocks[{$index}]`). The xo-move buttons stay for keyboards.',
|
|
22
|
+
'xo-select': 'On a checkbox in a row (inside xo-each): tick that row. `all:items` in the header ticks every row of the list. <xo-table selectable> does this for you.',
|
|
23
|
+
'xo-selected': 'On an xo-click button: run the operation once for every ticked row, one after another; the first failure stops the rest. With a value (`xo-selected="ids"`): ONE request whose body has the ticked ids in that field.',
|
|
24
|
+
'xo-with': 'On xo-click/xo-submit: fixed fields added to the request body, `field=value,other={path}` (typed from the request schema).',
|
|
25
|
+
'xo-confirm': 'On xo-click/xo-submit: ask before running, with a native dialog. `{path}` names the item: `xo-confirm="Delete {title}?"`.',
|
|
26
|
+
'xo-notice': 'On xo-click/xo-submit: after success, show this message in <xo-toast> (`{path}` and `{$selectedCount}` work).',
|
|
27
|
+
'xo-guard': 'On a <form xo-submit>: with unsaved changes the router asks before the page is left. `xo-guard="off"` disables it; any other text is the question to ask.',
|
|
28
|
+
'xo-route': 'On a <template> inside <xo-router>: the path this route answers, for example `/users/:id`; `*` is the not-found route. The content is inert until the route matches.',
|
|
29
|
+
'xo-layout': 'On a <template> inside <xo-router>: a layout (shell) that routes use with `layout="name"`. It must contain one element with xo-outlet.',
|
|
30
|
+
'xo-outlet': 'In a layout: the element where the matched route content is shown. The layout stays while only this content changes.',
|
|
31
|
+
'xo-optimistic': 'On xo-click: set these fields (`field=value,field2={path}`) on the current item immediately, and undo them if the call fails.',
|
|
32
|
+
'xo-refresh': 'On a button inside an <xo-view>: on click, fetch the view\'s data again (a "Try again" button after an error, or a refresh button).',
|
|
33
|
+
'xo-param': 'On an <input> or <select>: set the view parameter of that name to its value. On other elements, on click, set a view parameter and refetch: `name=value`, `name+=n`, `name-=n`, or `name~=value` (toggle value / -value, for sorting).',
|
|
34
|
+
'xo-open': 'Open the <xo-dialog> matched by this selector, for example `#add`.',
|
|
35
|
+
'xo-close': 'Close the <xo-dialog> this element is in.',
|
|
36
|
+
'xo-menu-trigger': 'On the button of <xo-menu> that opens it (the first button when none is marked).',
|
|
37
|
+
'xo-tooltip': 'On any element: a short description shown on hover and focus (and hidden on Escape). It is a description, not the accessible name.',
|
|
38
|
+
'xo-tab': 'On a section inside <xo-tabs>: the tab label. The section id is the tab key (its position when it has none).',
|
|
39
|
+
};
|
|
40
|
+
export const ELEMENT_DOCS = {
|
|
41
|
+
'xo-view': ['Declare a screen: `get` names the GET operation to read. Children are bound to the result. Add `live` to apply server-sent updates.', [['get', 'A path ("/users") or operationId.'], ['live', 'Apply live updates from the event stream.'], ['url-params', 'Comma-separated param names kept in the page URL (shareable, survive reload and Back): `status,q,sort,offset`.']]],
|
|
42
|
+
'xo-table': ['A table for the list in the enclosing <xo-view get>. Children become the actions cell of each row.', [['of', 'List field of the response (default `items`).'], ['columns', 'Comma-separated fields, optionally `field:Label`.'], ['sortable', 'Header buttons sort on the server through the `sort` query parameter.'], ['selectable', 'A checkbox per row and a select-all box; slot="bulk" children act on the ticked rows.'], ['filters', 'Comma-separated fields that get a search box above the table; typing sets the query parameter of the same name.'], ['page-size', 'Rows per page: sets `limit`, Previous/Next change `offset`. The response needs a `total`.'], ['cards', 'Draw each row as a card in a narrow pane (a theme does it; the table gets the class xo-cards). Cells carry data-label.'], ['title', 'The column that is the card title (default: the first).'], ['status', 'The column that is the status badge of the card (data-status).']]],
|
|
43
|
+
'xo-form': ['A form generated from the request schema of an operation.', [['op', 'The operation to submit to.'], ['submit', 'Text of the submit button.']]],
|
|
44
|
+
'xo-toast': ['Shows the message of the latest xo-error event on the page; hides on the next xo-done or after `timeout` seconds.', [['timeout', 'Seconds before it hides (default 6, 0 = until the next success).']]],
|
|
45
|
+
'xo-router': ['A History-API router declared in markup: one <template xo-route> per route, optional <template xo-layout> shells. Links are intercepted, aria-current marks the active one. Guards and leave-vetoes are JavaScript (`router.guard`, `router.beforeNavigate`).', [['base', 'Path prefix the app lives under, for example `/admin`.'], ['base-links', 'A root-relative link such as `/users` inside the router means `/admin/users`, so the same screens mount under any base. `rel="external"` leaves the app.']]],
|
|
46
|
+
'xo-tabs': ['Accessible tabs (@xoong/controls) from the sections that have xo-tab. Panels stay in the DOM, so form controls on other tabs still count; xo-reveal selects the tab of an invalid field.', [['value', 'Key of the selected tab (default: the first).'], ['label', 'Accessible name of the tab list.']]],
|
|
47
|
+
'xo-menu': ['An action menu (@xoong/controls): a trigger button and items (buttons or links) with arrow keys, Home/End, type-ahead and Escape. Drawn in the top layer where the browser supports the Popover API.', [['label', 'Accessible name of the menu.']]],
|
|
48
|
+
'xo-combobox': ['A form control (@xoong/controls) to pick from a list you can filter by typing. <option> children are the choices; `multiple` shows chips; the value is a string, or an array with `multiple`.', [['name', 'Form field path.'], ['multiple', 'Pick several values (chips).'], ['creatable', 'Allow a value that is not in the list.'], ['remote', 'Do not filter locally: listen to xo-search and set `el.options`.'], ['options-op', 'A GET operation whose list is the options (needs @xoong/data/view). With `option-value` and `option-label` (paths in each item, default id and name) and `p-<name>` parameters; a mutation of the list refreshes the options.'], ['placeholder', 'Hint text of the empty field.'], ['label', 'Accessible name when there is no <label>.']]],
|
|
49
|
+
'xo-carousel': ['A row of slides you move through (@xoong/controls): cards, a banner, a gallery. The children are the slides; they sit in a scroll container with scroll snap, so swiping is native. WAI-ARIA carousel pattern, slides out of view are inert.', [['items', 'Slides visible at once (default 1; 1.15 lets the next peek). Also --xo-carousel-items in CSS.'], ['dots', 'A button per position; a slide with data-thumb="url" shows that picture instead.'], ['counter', 'Show "1 / 5".'], ['no-arrows', 'Hide the previous and next buttons.'], ['loop', 'Next at the end returns to the first slide.'], ['autoplay', 'Move on by itself every n milliseconds (pauses on hover and focus; off with reduced motion; adds a pause button).'], ['label', 'Accessible name of the carousel.']]],
|
|
50
|
+
'xo-tags': ['A free-text tag input (@xoong/controls): type and press Enter or a comma to add a chip; Backspace removes the last. Value: an array of strings.', [['name', 'Form field path.'], ['placeholder', 'Hint text of the empty field.']]],
|
|
51
|
+
'xo-richtext': ['A rich text editor form control (@xoong/richtext): toolbar, headings, bold, italic, lists, quote, links. The value is sanitised HTML (p, h2, h3, strong, em, ul, ol, li, blockquote, br, a, img). `xo-pick-media` (cancelable) asks the app for an image.', [['name', 'Form field path.'], ['tools', 'Space-separated tools: undo redo style bold italic underline ul ol quote link image (h2 as a toggle).'], ['toolbar', '`full` or `compact` forces a toolbar size; by default the editor\'s own width decides (full from 580px).'], ['media', 'Show the image tool; it fires xo-pick-media.'], ['placeholder', 'Hint text of the empty editor.'], ['label', 'Accessible name of the editor.'], ['maxlength', 'Maximum number of text characters.']]],
|
|
52
|
+
'xo-switch': ['One setting on or off (@xoong/controls, WAI-ARIA switch). A form control with a boolean value; `change` fires when the person toggles it.', [['name', 'Form field path.'], ['checked', 'Start on.'], ['label', 'Accessible name.'], ['size', '`sm` for the compact switch.'], ['busy', 'A save is running: aria-busy, clicks ignored.'], ['disabled', 'Not available.'], ['readonly', 'Shown but not changeable.']]],
|
|
53
|
+
'xo-date': ['A date field with a calendar popover (@xoong/controls). The value is ISO `YYYY-MM-DD` (with `range`: `{ start, end }`); the field shows the locale format and accepts typing.', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['min', 'Earliest date, `YYYY-MM-DD`.'], ['max', 'Latest date, `YYYY-MM-DD`.'], ['range', 'Pick a start and an end (Clear and Apply in the calendar).'], ['week-start', 'First day of the week, 1 = Monday ... 7 = Sunday (default: the locale).'], ['placeholder', 'Hint text of the empty field.']]],
|
|
54
|
+
'xo-time': ['A time of day as two selects, hour and minute (@xoong/controls). The value is `HH:mm` or empty.', [['name', 'Form field path.'], ['label', 'Accessible name of the group.'], ['step', 'Minutes between choices (default 5).'], ['min', 'Earliest time, `HH:mm`.'], ['max', 'Latest time, `HH:mm`.']]],
|
|
55
|
+
'xo-slider': ['A number on a track (@xoong/controls, a native range input). `input` while dragging, `change` on release.', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['min', 'Lowest value (default 0).'], ['max', 'Highest value (default 100).'], ['step', 'Step (default 1).'], ['labels', 'Comma-separated names of the stops, read by screen readers (`Low,Medium,High`).']]],
|
|
56
|
+
'xo-stepper': ['A number with minus and plus buttons (@xoong/controls); arrows change by step, Page by ten steps, the value is clamped.', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['min', 'Lowest value.'], ['max', 'Highest value.'], ['step', 'Step (default 1).']]],
|
|
57
|
+
'xo-password': ['A password field with a show/hide button (@xoong/controls).', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['autocomplete', '`new-password` or `current-password`, for password managers.'], ['placeholder', 'Hint text of the empty field.']]],
|
|
58
|
+
'xo-copy': ['A read-only value with a Copy button (@xoong/controls); fires `copied`.', [['value', 'The text to show and copy.'], ['label', 'Accessible name of the field.']]],
|
|
59
|
+
'xo-dialog': ['A modal on the native <dialog>. Open it with xo-open, close it with xo-close or by submitting a form inside.', []],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** @param {{ operations: Record<string, any> }} table */
|
|
63
|
+
export function htmlData(table) {
|
|
64
|
+
const ops = Object.values(table.operations);
|
|
65
|
+
const refs = (filter) => ops.filter(filter).flatMap((o) => [o.id, `${o.method} ${o.path}`]);
|
|
66
|
+
const values = (list) => [...new Set(list)].sort().map((name) => ({ name }));
|
|
67
|
+
const mutations = values(refs((o) => o.method !== 'GET'));
|
|
68
|
+
const reads = values(ops.filter((o) => o.method === 'GET').flatMap((o) => [o.id, o.path]));
|
|
69
|
+
const params = [...new Set(ops.flatMap((o) => [...o.path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1])))].sort();
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
version: 1.1,
|
|
73
|
+
tags: [...XO_ELEMENTS, ...XO_UI_ELEMENTS, ...XO_CONTROL_ELEMENTS].map((name) => {
|
|
74
|
+
const [description, attrs] = ELEMENT_DOCS[name] ?? [name, []];
|
|
75
|
+
const attributes = attrs.map(([n, d]) => ({ name: n, description: d, ...(n === 'get' ? { valueSet: 'xo-reads' } : n === 'op' ? { valueSet: 'xo-mutations' } : {}) }));
|
|
76
|
+
if (name === 'xo-view') for (const p of params) attributes.push({ name: `p-${p}`, description: `Value of the path parameter "${p}" for the view's operation.` });
|
|
77
|
+
return { name, description, attributes };
|
|
78
|
+
}),
|
|
79
|
+
globalAttributes: [...XO_ATTRIBUTES, ...XO_UI_ATTRIBUTES, ...XO_CONTROL_ATTRIBUTES].map((name) => ({
|
|
80
|
+
name, description: ATTRIBUTE_DOCS[name] ?? name,
|
|
81
|
+
...(name === 'xo-click' || name === 'xo-submit' ? { valueSet: 'xo-mutations' } : {}),
|
|
82
|
+
})),
|
|
83
|
+
valueSets: [{ name: 'xo-mutations', values: mutations }, { name: 'xo-reads', values: reads }],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `xoong i18n extract`: the messages a project uses, with the source text as the default (ADR-0010).
|
|
4
|
+
* Reads `xo-t` in HTML (and in HTML written inside JavaScript template strings), and `text('key', 'default')` calls.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
7
|
+
import { join, extname } from 'node:path';
|
|
8
|
+
import { parseSpec } from '@xoong/i18n/spec';
|
|
9
|
+
import { parse as parseMessage, argsOf } from '@xoong/i18n/message';
|
|
10
|
+
|
|
11
|
+
const decode = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
12
|
+
const unescape = (s) => s.replace(/\\(['"\\])/g, '$1').replace(/\\n/g, '\n');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} source @param {string} [file]
|
|
16
|
+
* @returns {{ key: string, default: string, file: string, line: number }[]}
|
|
17
|
+
*/
|
|
18
|
+
export function extractMessages(source, file = '<input>') {
|
|
19
|
+
const found = [];
|
|
20
|
+
const lineOf = (index) => source.slice(0, index).split('\n').length;
|
|
21
|
+
// xo-t in markup: every start tag that carries it. The default of a bare key is the text up to the next tag; of attr:key the attribute.
|
|
22
|
+
for (const tag of source.matchAll(/<([a-z][\w-]*)\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\sxo-t=(?:"([^"]*)"|'([^']*)')((?:[^<>"']|"[^"]*"|'[^']*')*)>([^<]*)/gi)) {
|
|
23
|
+
const spec = tag[3] ?? tag[4];
|
|
24
|
+
const attrs = `${tag[2]} ${tag[5]}`;
|
|
25
|
+
for (const { attr, key } of parseSpec(spec)) {
|
|
26
|
+
let value;
|
|
27
|
+
if (attr) { const m = new RegExp(`\\s${attr}=(?:"([^"]*)"|'([^']*)')`).exec(attrs); value = m ? decode(m[1] ?? m[2]) : ''; } else value = decode(tag[6]).replace(/\s+/g, ' ').trim();
|
|
28
|
+
found.push({ key, default: value, file, line: lineOf(tag.index ?? 0) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// text('key', 'default') and t('key', args, 'default') in scripts
|
|
32
|
+
for (const m of source.matchAll(/\btext\(\s*(['"])([\w.-]+)\1\s*,\s*(['"])((?:[^'"\\\n]|\\.|(?!\3)['"])*)\3/g)) found.push({ key: m[2], default: unescape(m[4]), file, line: lineOf(m.index ?? 0) });
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Walk files and folders for .html and script files. */
|
|
37
|
+
export async function extractFrom(paths) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const visit = async (p) => {
|
|
40
|
+
const s = await stat(p);
|
|
41
|
+
if (s.isDirectory()) { for (const n of await readdir(p)) if (n !== 'node_modules' && !n.startsWith('.') && n !== 'dist') await visit(join(p, n)); } else if (['.html', '.htm', '.js', '.mjs', '.ts', '.mts'].includes(extname(p))) out.push(...extractMessages(await readFile(p, 'utf8'), p));
|
|
42
|
+
};
|
|
43
|
+
for (const p of paths) await visit(p);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A catalog template from extracted messages: { key: default }. A key used with different defaults is reported.
|
|
49
|
+
* @returns {{ catalog: Record<string, string>, problems: string[] }}
|
|
50
|
+
*/
|
|
51
|
+
export function toCatalog(messages) {
|
|
52
|
+
/** @type {Record<string, string>} */ const catalog = {};
|
|
53
|
+
const problems = [];
|
|
54
|
+
for (const m of messages) {
|
|
55
|
+
if (m.key in catalog && catalog[m.key] !== m.default) problems.push(`${m.file}:${m.line}: "${m.key}" has another default text than at its first use ("${catalog[m.key]}" vs "${m.default}")`);
|
|
56
|
+
else catalog[m.key] ??= m.default;
|
|
57
|
+
try { parseMessage(m.default); } catch (e) { problems.push(`${m.file}:${m.line}: the text of "${m.key}" is not a valid message: ${/** @type {Error} */ (e).message}`); }
|
|
58
|
+
}
|
|
59
|
+
return { catalog: Object.fromEntries(Object.entries(catalog).sort(([a], [b]) => a.localeCompare(b))), problems };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Problems of a translation against the source catalog: missing keys, extra keys, a message that cannot be read, placeholders that differ.
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
export function checkCatalog(source, translation, name = 'catalog') {
|
|
67
|
+
const problems = [];
|
|
68
|
+
for (const [key, text] of Object.entries(source)) {
|
|
69
|
+
if (!(key in translation)) { problems.push(`${name}: missing "${key}"`); continue; }
|
|
70
|
+
try {
|
|
71
|
+
const want = [...argsOf(parseMessage(text))].sort().join(',');
|
|
72
|
+
const have = [...argsOf(parseMessage(translation[key]))].sort().join(',');
|
|
73
|
+
if (want !== have) problems.push(`${name}: "${key}" uses {${have}} but the source uses {${want}}`);
|
|
74
|
+
} catch (e) { problems.push(`${name}: "${key}" is not a valid message: ${/** @type {Error} */ (e).message}`); }
|
|
75
|
+
}
|
|
76
|
+
for (const key of Object.keys(translation)) if (!(key in source)) problems.push(`${name}: "${key}" is not used by the source`);
|
|
77
|
+
return problems;
|
|
78
|
+
}
|
package/src/index.js
ADDED