@xoong/cli 0.0.39 → 0.0.41
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/package.json +4 -4
- package/src/build.js +7 -2
- package/src/check.js +7 -4
- package/src/cli.js +10 -8
- package/src/exclude.js +21 -0
- package/src/html-data.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"description": "The xoong command line: sync (OpenAPI to typed operations), doctor, check (lint screens), dev, build (strict-CSP static output), render and i18n.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"parse5": "^8.1.0",
|
|
19
19
|
"yaml": "^2.8.0",
|
|
20
|
-
"@xoong/server": "0.0.
|
|
21
|
-
"@xoong/core": "0.0.
|
|
22
|
-
"@xoong/i18n": "0.0.
|
|
20
|
+
"@xoong/server": "0.0.41",
|
|
21
|
+
"@xoong/core": "0.0.41",
|
|
22
|
+
"@xoong/i18n": "0.0.41"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"src"
|
package/src/build.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* - TypeScript is stripped (erasable syntax only) and .ts files are written as .js;
|
|
11
11
|
* - `--base` prefixes root-absolute URLs for an app served under a path;
|
|
12
12
|
* - nothing is minified or bundled; every file is the one you wrote (readable in the browser's tools).
|
|
13
|
+
* HTML files that are not pages (partials, fixtures, drafts) are kept out of the start list with `--exclude <glob>` (repeatable) or `"build": { "exclude": [...] }` in
|
|
14
|
+
* xoong.config.json (paths relative to the root; see exclude.js). A file that a page references is still published.
|
|
13
15
|
* What cannot be seen (a fetch() of a data file, an image named in JavaScript) is added with `--include <file-or-folder>`.
|
|
14
16
|
*/
|
|
15
17
|
import { readFile, writeFile, mkdir, readdir, stat, copyFile, realpath } from 'node:fs/promises';
|
|
@@ -18,6 +20,7 @@ import { gzipSync } from 'node:zlib';
|
|
|
18
20
|
import { parse } from 'parse5';
|
|
19
21
|
import { findPackages, packagePath, stripTypes } from '@xoong/server/node';
|
|
20
22
|
import { loadConfig } from './dev.js';
|
|
23
|
+
import { excluder } from './exclude.js';
|
|
21
24
|
import { buildImportMap } from '@xoong/server';
|
|
22
25
|
|
|
23
26
|
const SCRIPT_EXT = new Set(['.js', '.mjs', '.ts', '.mts']);
|
|
@@ -28,10 +31,10 @@ const isLocal = (u) => !!u && !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(u);
|
|
|
28
31
|
export const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'";
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* @param {{ root?: string, out?: string, base?: string, include?: string[] }} [options]
|
|
34
|
+
* @param {{ root?: string, out?: string, base?: string, include?: string[], exclude?: string[] }} [options]
|
|
32
35
|
* @returns {Promise<{ out: string, files: { path: string, bytes: number, gzip: number }[], warnings: string[], csp: string }>}
|
|
33
36
|
*/
|
|
34
|
-
export async function build({ root = '.', out = 'dist', base = '', include = [], packages: extra } = {}) {
|
|
37
|
+
export async function build({ root = '.', out = 'dist', base = '', include = [], exclude, packages: extra } = {}) {
|
|
35
38
|
const rootDir = await realpath(resolve(root)); // real path: packages are found by theirs, so an app inside a symlinked package must compare equal
|
|
36
39
|
// the output folder may not exist yet: resolve the part that does
|
|
37
40
|
const realOut = async (p) => { try { return await realpath(p); } catch { const up = dirname(p); return up === p ? p : join(await realOut(up), basename(p)); } };
|
|
@@ -175,10 +178,12 @@ export async function build({ root = '.', out = 'dist', base = '', include = [],
|
|
|
175
178
|
|
|
176
179
|
// pages first, then whatever they pull in
|
|
177
180
|
const pages = [];
|
|
181
|
+
const skip = excluder([...(exclude ?? []), ...(((await loadConfig(rootDir)).build ?? {}).exclude ?? [])]);
|
|
178
182
|
const walk = async (dir) => {
|
|
179
183
|
for (const name of await readdir(dir)) {
|
|
180
184
|
if (name === 'node_modules' || name.startsWith('.') || resolve(dir, name) === outDir) continue;
|
|
181
185
|
const p = join(dir, name);
|
|
186
|
+
if (skip(relative(rootDir, p))) continue;
|
|
182
187
|
if ((await stat(p)).isDirectory()) await walk(p); else if (/\.html?$/i.test(name)) pages.push(urlOf(p));
|
|
183
188
|
}
|
|
184
189
|
};
|
package/src/check.js
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* XO252 <xo-form op> operation has no simple request fields to generate a form from
|
|
28
28
|
*/
|
|
29
29
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
30
|
-
import {
|
|
30
|
+
import { excluder } from './exclude.js';
|
|
31
|
+
import { join, extname, relative } from 'node:path';
|
|
31
32
|
import { parse } from 'parse5';
|
|
32
33
|
import { nearest } from '@xoong/core/closest';
|
|
33
34
|
import { XO_ATTRIBUTES, XO_ELEMENTS, XO_UI_ATTRIBUTES, XO_UI_ELEMENTS, XO_CONTROL_ATTRIBUTES, XO_CONTROL_ELEMENTS } from '@xoong/core/syntax';
|
|
@@ -317,9 +318,11 @@ export function checkScript(source, table, file = '<input>') {
|
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
/** Collect .html files from files and folders (skips node_modules and dot folders). */
|
|
320
|
-
export async function collectHtml(paths) {
|
|
321
|
+
export async function collectHtml(paths, { exclude } = {}) {
|
|
321
322
|
const out = [];
|
|
323
|
+
const skip = excluder(exclude);
|
|
322
324
|
const visit = async (p) => {
|
|
325
|
+
if (skip(relative('.', p))) return;
|
|
323
326
|
const s = await stat(p);
|
|
324
327
|
if (s.isDirectory()) {
|
|
325
328
|
for (const name of await readdir(p)) if (!['node_modules', 'dist', 'build', 'coverage'].includes(name) && !name.startsWith('.')) await visit(join(p, name));
|
|
@@ -330,8 +333,8 @@ export async function collectHtml(paths) {
|
|
|
330
333
|
}
|
|
331
334
|
|
|
332
335
|
/** @returns {Promise<{ findings: Finding[], files: number }>} */
|
|
333
|
-
export async function checkFiles(paths, table) {
|
|
334
|
-
const files = await collectHtml(paths);
|
|
336
|
+
export async function checkFiles(paths, table, options = {}) {
|
|
337
|
+
const files = await collectHtml(paths, options);
|
|
335
338
|
const findings = [];
|
|
336
339
|
for (const file of files) {
|
|
337
340
|
const source = await readFile(file, 'utf8');
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { sync, doctor } from './commands.js';
|
|
4
|
-
import { startDev, parseProxyFlags } from './dev.js';
|
|
4
|
+
import { startDev, parseProxyFlags, loadConfig } from './dev.js';
|
|
5
5
|
import { checkFiles, formatFindings } from './check.js';
|
|
6
6
|
import { scaffold } from './scaffold.js';
|
|
7
7
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
@@ -12,9 +12,9 @@ const HELP = `xoong <command>
|
|
|
12
12
|
sync <openapi.json|yaml|url> [--out .xoong] [--hints hints.json] generate operations.json and api.d.ts
|
|
13
13
|
doctor <openapi.json|yaml|url> [--hints hints.json] [--all] audit the OpenAPI document for xoong
|
|
14
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
|
|
15
|
+
check <file|folder>... [--operations .xoong/operations.json] [--format text|json] [--exclude glob]... check HTML screens
|
|
16
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)
|
|
17
|
+
build [--root .] [--out dist] [--base /admin] [--include path]... [--exclude glob]... static output for strict-CSP hosting (no import map, no inline code)
|
|
18
18
|
i18n extract <html|js files or folders>... [--out catalogs/en.json] list xo-t keys with their source text
|
|
19
19
|
i18n check <catalog.json> --source <en.json> missing keys, wrong placeholders, invalid messages
|
|
20
20
|
dev [--root .] [--port 5173] [--proxy /api=http://localhost:8080] [--spa] serve the app (static, proxy, .ts)
|
|
@@ -45,8 +45,9 @@ try {
|
|
|
45
45
|
let table;
|
|
46
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
47
|
const format = flag('--format', 'text');
|
|
48
|
-
const targets = rest.filter((v, i) => !v.startsWith('--') && !['--operations', '--format'].includes(rest[i - 1]));
|
|
49
|
-
const
|
|
48
|
+
const targets = rest.filter((v, i) => !v.startsWith('--') && !['--operations', '--format', '--exclude'].includes(rest[i - 1]));
|
|
49
|
+
const exclude = [...rest.flatMap((v, i) => (rest[i - 1] === '--exclude' ? [v] : [])), ...(((await loadConfig('.')).check ?? {}).exclude ?? [])];
|
|
50
|
+
const { findings, files } = await checkFiles(targets, table, { exclude });
|
|
50
51
|
console.log(format === 'json' ? JSON.stringify(findings, null, 2) : formatFindings(findings, { files }));
|
|
51
52
|
process.exitCode = findings.some((f) => f.level === 'error') ? 1 : 0;
|
|
52
53
|
} else if (cmd === 'render' && rest[0] && flag('--api')) {
|
|
@@ -75,11 +76,12 @@ try {
|
|
|
75
76
|
console.log(problems.length ? `${problems.length} problems` : 'ok');
|
|
76
77
|
process.exitCode = problems.length ? 1 : 0;
|
|
77
78
|
} 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)`);
|
|
79
|
+
const unknown = rest.filter((v, i) => v.startsWith('-') && !['--root', '--out', '--base', '--include', '--exclude'].includes(v) && true);
|
|
80
|
+
if (unknown.length) throw new Error(`xoong build: unknown option ${unknown.join(', ')} (known: --root, --out, --base, --include, --exclude)`);
|
|
80
81
|
const { build, CSP } = await import('./build.js');
|
|
81
82
|
const include = rest.flatMap((v, i) => (rest[i - 1] === '--include' ? [v] : []));
|
|
82
|
-
const
|
|
83
|
+
const exclude = rest.flatMap((v, i) => (rest[i - 1] === '--exclude' ? [v] : []));
|
|
84
|
+
const r = await build({ root: flag('--root', '.'), out: flag('--out', 'dist'), base: flag('--base', ''), include, exclude });
|
|
83
85
|
const total = r.files.reduce((n, f) => n + f.bytes, 0);
|
|
84
86
|
const gz = r.files.reduce((n, f) => n + f.gzip, 0);
|
|
85
87
|
console.log(`built ${r.files.length} files into ${r.out} (${(total / 1024).toFixed(1)} KB, ${(gz / 1024).toFixed(1)} KB gzip)`);
|
package/src/exclude.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* A tiny glob for `--exclude` and `exclude` in xoong.config.json: paths relative to the project root, `/` separators.
|
|
4
|
+
* `*` any characters except `/` `**` any characters including `/` `?` one character a trailing `/` means the folder and everything in it.
|
|
5
|
+
* A pattern without a `/` matches that name at any depth (`*.draft.html`, `partials`). A folder that matches is skipped with all it holds.
|
|
6
|
+
* @param {string[] | undefined} patterns
|
|
7
|
+
* @returns {(relativePath: string) => boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function excluder(patterns) {
|
|
10
|
+
const list = (patterns ?? []).map((p) => String(p).trim().replaceAll('\\', '/').replace(/^\.\//, '')).filter(Boolean);
|
|
11
|
+
const regexes = list.map((p) => {
|
|
12
|
+
if (p.endsWith('/')) p += '**';
|
|
13
|
+
if (!p.includes('/')) p = `**/${p}`;
|
|
14
|
+
const source = p.replace(/[.+^${}()|[\]]/g, '\\$&').replace(/\*\*\//g, '\u0000').replace(/\*\*/g, '\u0001').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\u0000/g, '(?:.*/)?').replace(/\u0001/g, '.*');
|
|
15
|
+
return new RegExp(`^${source}$`);
|
|
16
|
+
});
|
|
17
|
+
return (relativePath) => {
|
|
18
|
+
const path = relativePath.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
19
|
+
return regexes.some((r) => r.test(path));
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/html-data.js
CHANGED
|
@@ -35,6 +35,7 @@ export const ATTRIBUTE_DOCS = {
|
|
|
35
35
|
'xo-open': 'Open the <xo-dialog> matched by this selector, for example `#add`.',
|
|
36
36
|
'xo-close': 'Close the <xo-dialog> this element is in.',
|
|
37
37
|
'xo-menu-trigger': 'On the button of <xo-menu> that opens it (the first button when none is marked).',
|
|
38
|
+
'xo-consent-open': 'On any element: a click shows the <xo-consent> bar again (cookie settings).',
|
|
38
39
|
'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.',
|
|
39
40
|
'xo-tab': 'On a section inside <xo-tabs>: the tab label. The section id is the tab key (its position when it has none).',
|
|
40
41
|
};
|
|
@@ -50,7 +51,7 @@ export const ELEMENT_DOCS = {
|
|
|
50
51
|
'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.']]],
|
|
51
52
|
'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.']]],
|
|
52
53
|
'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.']]],
|
|
53
|
-
'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.']]],
|
|
54
|
+
'xo-switch': ['One setting on or off (@xoong/controls, WAI-ARIA switch). A form control with a boolean value; the text inside is the visible label (`label.xo-switch-row`); `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.']]],
|
|
54
55
|
'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.']]],
|
|
55
56
|
'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`.']]],
|
|
56
57
|
'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`).']]],
|
|
@@ -58,6 +59,13 @@ export const ELEMENT_DOCS = {
|
|
|
58
59
|
'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.']]],
|
|
59
60
|
'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.']]],
|
|
60
61
|
'xo-image': ['An image that loads lazily, keeps its space (no layout jump), shows a placeholder while loading and a fallback on error (@xoong/controls). `data-state`: empty, loading, loaded, error.', [['src', 'The image URL (in an xo-view: `src="{cover.url}"`).'], ['alt', 'Required: the text for the image, or `alt=""` when it is decorative.'], ['width', 'Intrinsic width, reserves the space with height.'], ['height', 'Intrinsic height.'], ['ratio', 'Aspect ratio to reserve instead of width/height: `16/9`, `1.5`.'], ['fit', '`cover` (default) or `contain`.'], ['position', 'object-position, e.g. `top`.'], ['srcset', 'Responsive sources, passed to the img.'], ['sizes', 'Display sizes for srcset.'], ['priority', 'The image above the fold: loads at once with high priority (not lazy).'], ['loading', '`lazy` (default) or `eager`.'], ['fallback', 'A src to use once when the image fails.']]],
|
|
62
|
+
'xo-theme-toggle': ['A button that switches between the light and the dark theme (@xoong/controls): sets `data-theme` on <html>, saves the choice, follows the system until chosen. Fires `xo-theme-change`.', [['storage', 'localStorage key (default `xoong-theme`).']]],
|
|
63
|
+
'xo-accordion': ['A stack of native <details> as an accordion (@xoong/controls); `single` allows one open at a time; arrows move between summaries. Fires `xo-accordion-change`.', [['single', 'Only one section open at a time.']]],
|
|
64
|
+
'xo-popover': ['A small panel opened by a button (@xoong/controls): Escape or an outside click closes it and the focus returns to the trigger. `xo-popover-open` / `xo-popover-close`.', [['anchor', 'Selector of the trigger (default: the element before it).'], ['label', 'Accessible name of the panel.'], ['open', 'Open at the start.']]],
|
|
65
|
+
'xo-consent': ['A cookie consent bar that remembers the choice (@xoong/controls). The text inside is the message. Fires `xo-consent` with the choices.', [['categories', 'Comma-separated categories: `analytics,marketing`.'], ['storage', 'localStorage key (default `xoong-consent`).'], ['label', 'Accessible name of the bar.']]],
|
|
66
|
+
'xo-file': ['A file field with a drop zone (@xoong/controls). The value is a File (an array with `multiple`). Refused files fire `xo-file-reject`.', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['accept', 'Allowed types: `image/*`, `.pdf`.'], ['max-size', 'Largest file: bytes or `2MB`.'], ['multiple', 'Several files.'], ['hint', 'Text under the prompt.']]],
|
|
67
|
+
'xo-command': ['A command palette (@xoong/controls): Ctrl/Cmd+K, type to filter, arrows, Enter. The children (links, buttons with `data-group`) are the commands. Fires `xo-command-run`.', [['shortcut', 'The key used with Ctrl/Cmd (default `k`).'], ['label', 'Accessible name of the dialog.']]],
|
|
68
|
+
'xo-chart': ['A bar or line chart drawn from data, with a table of the same numbers (@xoong/controls).', [['type', '`bar` (default) or `line`.'], ['label', 'Accessible name of the chart.'], ['data', 'JSON: `[{"label":"Mon","value":12}]` or `[12, 18]`; or set `el.rows`.'], ['unit', 'Appended to values: `k`, `%`.'], ['heading', 'Label column of the table.'], ['value-heading', 'Value column of the table.']]],
|
|
61
69
|
'xo-dialog': ['A modal on the native <dialog>. Open it with xo-open, close it with xo-close or by submitting a form inside.', []],
|
|
62
70
|
};
|
|
63
71
|
|