@xoong/cli 0.0.39 → 0.0.42

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xoong/cli",
3
- "version": "0.0.39",
3
+ "version": "0.0.42",
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.39",
21
- "@xoong/core": "0.0.39",
22
- "@xoong/i18n": "0.0.39"
20
+ "@xoong/server": "0.0.42",
21
+ "@xoong/core": "0.0.42",
22
+ "@xoong/i18n": "0.0.42"
23
23
  },
24
24
  "files": [
25
25
  "src"
@@ -42,7 +42,7 @@
42
42
  "bugs": {
43
43
  "url": "https://github.com/fpt-duongtx/xoong/issues"
44
44
  },
45
- "homepage": "https://github.com/fpt-duongtx/xoong#readme",
45
+ "homepage": "https://fpt-duongtx.github.io/xoong/",
46
46
  "publishConfig": {
47
47
  "access": "public"
48
48
  }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * OpenAPI request schema -> the compact node tree that forms use at runtime (`bodySchema` in operations.json; see
4
- * packages/data/src/schema.js for the node shapes and docs/decisions/0009-forms-from-the-api-schema.md).
4
+ * packages/forms/src/schema.js for the node shapes and docs/decisions/0009-forms-from-the-api-schema.md).
5
5
  */
6
6
 
7
7
  const refName = (r) => r.slice(r.lastIndexOf('/') + 1);
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 { join, extname } from 'node:path';
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 { findings, files } = await checkFiles(targets, table);
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 r = await build({ root: flag('--root', '.'), out: flag('--out', 'dist'), base: flag('--base', ''), include });
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,25 @@ 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.']]],
69
+ 'xo-range-slider': ['A minimum and a maximum on one track (@xoong/controls): two native range inputs, thumbs cannot cross. The value is `[min, max]`; the attribute is `value="20,70"`.', [['name', 'Form field path.'], ['label', 'Accessible name of the group.'], ['min', 'Lowest value (default 0).'], ['max', 'Highest value (default 100).'], ['step', 'Step (default 1).'], ['min-label', 'Accessible name of the minimum thumb.'], ['max-label', 'Accessible name of the maximum thumb.']]],
70
+ 'xo-segmented': ['A group of buttons where one is chosen (@xoong/controls). The children `<button value>` are the segments; `aria-pressed` on each; arrows move the focus.', [['name', 'Form field path.'], ['label', 'Accessible name of the group.'], ['multiple', 'Each segment toggles on its own; the value is an array.'], ['clearable', 'A click on the chosen segment unchooses it.']]],
71
+ 'xo-split-button': ['A main action with a menu of related actions (@xoong/controls). The first child is the main button; the others are the menu items.', [['more', 'Accessible name of the arrow button (default "More options").']]],
72
+ 'xo-textarea': ['A textarea with a character counter and a thin progress bar toward `maxlength` (@xoong/controls); over the limit it is invalid and the text is kept.', [['name', 'Form field path.'], ['label', 'Accessible name.'], ['maxlength', 'Character limit.'], ['rows', 'Visible rows (default 3).'], ['placeholder', 'Hint text of the empty field.']]],
73
+ 'xo-stat': ['A KPI tile (@xoong/controls): label, value, a change against before and a sparkline from a series.', [['label', 'What is measured.'], ['value', 'The number as text (formatted by you).'], ['delta', 'Change in percent; positive shows an up arrow, negative a down arrow.'], ['delta-label', 'Text after the change, e.g. "vs last week".'], ['series', 'Comma-separated numbers for the sparkline.'], ['trend', '`down-is-good` when a fall is an improvement.']]],
74
+ 'xo-otp': ['One-time code: one box per digit (@xoong/controls); the value is the digit string; fires `complete` when all are filled. Paste splits across the boxes.', [['name', 'Form field path.'], ['label', 'Accessible name of the group.'], ['length', 'Number of digits (default 6).'], ['value', 'The start value.'], ['required', 'Must be filled.'], ['disabled', 'Not available.'], ['readonly', 'Shown but not changeable.']]],
75
+ 'xo-avatar': ['A round badge with initials from the name, or a picture (@xoong/controls); `aria-label` is the name and the status.', [['name', 'Full name (initials and accessible name).'], ['src', 'Picture URL; the initials stay when it fails.'], ['size', '`sm` or `lg`.'], ['status', '`ok`, `warn` or `muted`.']]],
76
+ 'xo-avatars': ['A stack of avatars with a +N badge for the rest (@xoong/controls); a list.', [['max', 'How many to show.'], ['label', 'Accessible name of the list.']]],
77
+ 'xo-progress': ['A progress bar (@xoong/controls); no `value` means indeterminate.', [['value', 'Current amount.'], ['max', 'Full amount (default 100).'], ['label', 'Accessible name.'], ['thin', 'The thin variant.']]],
78
+ 'xo-spinner': ['A loading spinner (@xoong/controls), `role=status`; `label=""` makes it decorative.', [['label', 'What is loading.'], ['size', '`md` or `lg`.']]],
79
+ 'xo-steps': ['Progress through a flow of steps (@xoong/controls): the children are the step titles; `aria-current=step` marks the current one; done steps show a check.', [['current', 'Index of the current step (from 0).'], ['label', 'Accessible name of the list.']]],
80
+ 'xo-inline-edit': ['Text you rename in place (@xoong/controls): select it or press the Rename button; Enter saves, Escape cancels. Fires `save` and `cancel`.', [['name', 'Form field path.'], ['value', 'The text.'], ['label', 'Accessible name of the Rename button.'], ['disabled', 'Not available.'], ['readonly', 'Not editable.']]],
61
81
  '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
82
  };
63
83
 
package/src/openapi.js CHANGED
@@ -293,7 +293,7 @@ export function analyze(doc, { hints } = {}) {
293
293
  const lost = [];
294
294
  const builtBodyNode = bodySchema && !multipart ? bodySchemaOf(bodySchema, schemas, hints, lost) : undefined;
295
295
  const builtBody = () => builtBodyNode;
296
- for (const where of new Set(lost)) problems.push({ level: 'warn', code: 'XO161', where: `${method.toUpperCase()} ${path}`, message: `a union in ${where} cannot be told apart, so a generated form draws it as free-form`, fix: 'Add a discriminator (propertyName) or give each member a property with one fixed value (const or a one-value enum)' });
296
+ for (const where of new Set(lost)) problems.push({ level: 'warn', code: 'XO161', where: `${method.toUpperCase()} ${path}`, message: `a union in ${where} cannot be told apart, so a generated form draws it as free-form`, fix: 'Add a discriminator (propertyName; inline members also need the fixed value of that property) or give each member a property with a different fixed value (const or a one-value enum)' });
297
297
  operations[id] = {
298
298
  id, method: method.toUpperCase(), path,
299
299
  ...(params.some((p) => p.in === 'query') ? { query: params.filter((p) => p.in === 'query').map((p) => p.name) } : {}),