@astryxdesign/cli 0.7.12-canary.f543ec7 → 0.7.12

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/api/build/blocks/blocks.d.mts +41 -0
  3. package/api/build/blocks/blocks.mjs +118 -68
  4. package/api/component/__fixtures__/resolutions/modules.txt +3 -3
  5. package/api/docs/_adapter.d.mts +22 -0
  6. package/api/docs/_adapter.mjs +81 -3
  7. package/api/docs/docs.doc.mjs +5 -5
  8. package/api/docs/docs.type.d.mts +21 -0
  9. package/api/docs/docs.type.mjs +11 -0
  10. package/api/docs/list/list.d.mts +5 -1
  11. package/api/docs/list/list.mjs +72 -6
  12. package/api/docs/node/node.mjs +2 -40
  13. package/api/doctor/doctor.d.mts +4 -4
  14. package/api/doctor/doctor.mjs +248 -78
  15. package/api/icon/_authoring.mjs +8 -1
  16. package/api/icon/add/add.mjs +45 -5
  17. package/api/icon/icon.type.d.mts +24 -0
  18. package/api/icon/icon.type.mjs +6 -0
  19. package/api/icon/iconAdd.doc.mjs +1 -1
  20. package/api/icon/map/map.mjs +11 -4
  21. package/api/index.d.mts +1 -0
  22. package/api/index.mjs +1 -0
  23. package/api/integration/add-helpers.d.mts +5 -1
  24. package/api/integration/add-helpers.mjs +1 -1
  25. package/api/render/_adapter.mjs +9 -0
  26. package/api/render/render.doc.mjs +3 -3
  27. package/api/render/render.type.d.mts +1 -1
  28. package/api/render/render.type.mjs +1 -1
  29. package/api/search/ORDERING.md +3 -0
  30. package/api/search/_adapter.d.mts +32 -3
  31. package/api/search/_adapter.mjs +50 -22
  32. package/api/search/_fit-order.d.mts +47 -0
  33. package/api/search/_fit-order.mjs +257 -0
  34. package/api/search/_main-searcher.mjs +15 -5
  35. package/api/search/exportSearchIndex.doc.d.mts +10 -0
  36. package/api/search/exportSearchIndex.doc.mjs +58 -0
  37. package/api/search/rankSearchIndex.doc.d.mts +10 -0
  38. package/api/search/rankSearchIndex.doc.mjs +79 -0
  39. package/api/search/search-index.d.mts +44 -0
  40. package/api/search/search-index.mjs +270 -0
  41. package/api/search/search.type.d.mts +28 -0
  42. package/api/search/search.type.mjs +17 -0
  43. package/api/swizzle/_moduleGraph.d.mts +7 -0
  44. package/api/swizzle/_moduleGraph.mjs +191 -83
  45. package/api/swizzle/copy/copy.mjs +28 -6
  46. package/api/template/skeleton/skeleton.mjs +1 -1
  47. package/api/template/template.mjs +1 -0
  48. package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +1 -1
  49. package/assets/codemods/transforms/v0.1.0/index.mjs +1 -1
  50. package/assets/discover/official-packages.json +16 -0
  51. package/assets/docs/styling-overview.doc.mjs +3 -3
  52. package/assets/docs/tree/icon-sets.doc.mjs +4 -4
  53. package/assets/docs/tree/render-pages.doc.mjs +3 -3
  54. package/assets/docs/tree/tailwind.doc.mjs +41 -1
  55. package/assets/docs/tree/type-scale.doc.mjs +19 -2
  56. package/assets/templates/blocks/components/Hooks/useInputStatusIconHookUsage.tsx +1 -0
  57. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
  58. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
  59. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
  60. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
  61. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
  62. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
  63. package/assets/templates/blocks/components/PowerSearch/usePowerSearchConfigHookUsage.doc.mjs +14 -0
  64. package/assets/templates/blocks/components/PowerSearch/usePowerSearchConfigHookUsage.tsx +83 -0
  65. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
  66. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +41 -0
  67. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
  68. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
  69. package/assets/templates/pages/table-filter/page.tsx +15 -23
  70. package/assets/templates/pages/table-grouped/page.tsx +30 -30
  71. package/assets/templates/pages/table-inbox/page.tsx +3 -6
  72. package/assets/templates/themes/butter/butterTheme.ts +1 -1
  73. package/assets/templates/themes/gothic/gothicTheme.ts +16 -0
  74. package/assets/templates/themes/matcha/matchaTheme.ts +1 -1
  75. package/assets/templates/themes/neutral/neutralTheme.ts +6 -0
  76. package/assets/templates/themes/y2k/y2kPaletteRefs.generated.ts +1 -1
  77. package/assets/templates/themes/y2k/y2kTheme.ts +1 -1
  78. package/clients/cli/commands/docs.doc.mjs +5 -1
  79. package/clients/cli/commands/docs.mjs +30 -2
  80. package/clients/cli/commands/experimental/icon.mjs +25 -4
  81. package/clients/cli/commands/experimental-render.doc.mjs +1 -1
  82. package/clients/cli/commands/template.mjs +204 -181
  83. package/clients/cli/lib/resolve-theme.mjs +16 -7
  84. package/foundation/discovery/component-discovery.mjs +1 -1
  85. package/foundation/discovery/hook-discovery.mjs +31 -8
  86. package/foundation/discovery/icon-set-writer.d.mts +21 -5
  87. package/foundation/discovery/icon-set-writer.mjs +114 -13
  88. package/foundation/discovery/template-adapter.mjs +2 -2
  89. package/foundation/doc-compiler/import.d.mts +5 -1
  90. package/foundation/doc-compiler/import.mjs +40 -6
  91. package/foundation/fs/import-url.d.mts +20 -0
  92. package/foundation/fs/import-url.mjs +22 -0
  93. package/foundation/fs/module-loader.d.mts +24 -3
  94. package/foundation/fs/module-loader.mjs +69 -47
  95. package/foundation/render/charts.d.mts +59 -0
  96. package/foundation/render/charts.mjs +216 -0
  97. package/foundation/render/embedded-imports.d.mts +7 -7
  98. package/foundation/render/embedded-imports.mjs +63 -54
  99. package/foundation/render/page-host.d.mts +4 -0
  100. package/foundation/render/page-host.mjs +45 -0
  101. package/foundation/render/page-worker.mjs +350 -57
  102. package/foundation/render/render-page.d.mts +11 -6
  103. package/foundation/render/render-page.mjs +164 -54
  104. package/foundation/render/ts-paths.d.mts +63 -0
  105. package/foundation/render/ts-paths.mjs +138 -0
  106. package/foundation/response/error-codes.doc.mjs +3 -3
  107. package/foundation/response/error-codes.mjs +3 -2
  108. package/foundation/text/levenshtein.d.mts +6 -1
  109. package/foundation/text/levenshtein.mjs +25 -9
  110. package/foundation/text/suggest-order.d.mts +8 -1
  111. package/foundation/text/suggest-order.mjs +72 -6
  112. package/foundation/xle/registry-core.mjs +1 -1
  113. package/package.json +7 -6
@@ -18,13 +18,15 @@
18
18
 
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
- import {Worker} from 'node:worker_threads';
21
+ import {fork, spawnSync} from 'node:child_process';
22
+ import {fileURLToPath} from 'node:url';
22
23
  import postcss from 'postcss';
23
24
  import {assetDataUri, isAssetBytes, isAssetPath, RenderAssetError} from './assets.mjs';
24
25
  import {classesIn, cutCss, layerOrderOf} from './css-cut.mjs';
25
26
  import {sourceBlock} from './source-block.mjs';
26
27
  import {embeddedPathProblem} from './embedded-files.mjs';
27
28
  import {resolveServed} from './served-packages.mjs';
29
+ import {CHART_CSS} from './charts.mjs';
28
30
 
29
31
  /** How long one page may take to render before it is stopped. */
30
32
  export const RENDER_TIMEOUT_MS = 60_000;
@@ -33,7 +35,7 @@ export {PAGE_EXTENSIONS} from './embedded-files.mjs';
33
35
 
34
36
  /**
35
37
  * @typedef {object} RenderWarning
36
- * @property {'dropped-children' | 'remote-asset' | 'missing-asset' | 'page-output' | 'page-script' | 'no-astryx-styles' | 'chart-size' | 'not-embedded'} code - What kind of problem.
38
+ * @property {'dropped-children' | 'remote-asset' | 'missing-asset' | 'page-output' | 'page-script' | 'no-astryx-styles' | 'chart-size' | 'not-embedded' | 'node-crash'} code - What kind of problem.
37
39
  * @property {string} message - What happened and what to do, in one sentence.
38
40
  */
39
41
 
@@ -220,65 +222,158 @@ function expandImports(file, css, ctx) {
220
222
 
221
223
  /** @typedef {{dir: string, integrations: readonly string[]}} ServedSet */
222
224
 
225
+ /** Signals that Node itself or a native module raises when it fails. */
226
+ const CRASH_SIGNALS = new Set(['SIGSEGV', 'SIGBUS', 'SIGILL', 'SIGFPE', 'SIGABRT', 'SIGTRAP']);
227
+
223
228
  /**
224
- * Run the page in a worker, stopping it after `timeoutMs`.
225
- * @param {string} entry
226
- * @param {string} root
229
+ * How a native crash ended a process, or null when it was not one. On
230
+ * Windows a crash arrives as an exit status of 0xC0000000 and above (such as
231
+ * 0xC0000005, an access violation), not as a signal.
232
+ * @param {number | null} code
233
+ * @param {NodeJS.Signals | null} signal
234
+ */
235
+ function crashOf(code, signal) {
236
+ if (signal) return CRASH_SIGNALS.has(signal) ? signal : null;
237
+ if (code !== null && code >>> 0 >= 0xc0000000) return `0x${(code >>> 0).toString(16).toUpperCase()}`;
238
+ return null;
239
+ }
240
+
241
+ /**
242
+ * Whether this Node crashes when it splits text with Intl.Segmenter. Some
243
+ * Node builds do, and Astryx components such as Avatar split text with it.
244
+ */
245
+ function segmenterCrashes() {
246
+ const probe = spawnSync(
247
+ process.execPath,
248
+ ['-e', "[...new Intl.Segmenter('en', {granularity: 'grapheme'}).segment('a b')]"],
249
+ {stdio: 'ignore', timeout: 10_000},
250
+ );
251
+ return crashOf(probe.status, probe.signal) !== null;
252
+ }
253
+
254
+ /**
255
+ * The error for a page process that a crash or a signal ended. A crash comes
256
+ * from Node itself or a native module, never from the page's JavaScript.
257
+ * Every message names this Node.
258
+ * @param {string} how the crash (a signal name or a Windows status) or the stopping signal
259
+ * @param {{crashed: boolean, withoutSegmenter: boolean}} state
260
+ */
261
+ function crashError(how, {crashed, withoutSegmenter}) {
262
+ const node = `Node ${process.version} (${process.execPath})`;
263
+ const detail = {signal: how, node: process.version, nodePath: process.execPath};
264
+ if (!crashed) {
265
+ const memory = how === 'SIGKILL' ? ' The system stops a process this way when the machine runs out of memory.' : '';
266
+ return new RenderError('ERR_RENDER_FAILED', `${node} was stopped by a signal (${how}) while it rendered the page.${memory}`, detail);
267
+ }
268
+ const cause = withoutSegmenter ? ' It crashed again with Intl.Segmenter removed, so the fault is elsewhere in this Node or in a native module.' : '';
269
+ return new RenderError(
270
+ 'ERR_RENDER_FAILED',
271
+ `${node} crashed (${how}) while it rendered the page. A fault in Node or in a native module causes this, not the page's code.${cause} Render with an official Node.js build of another version.`,
272
+ detail,
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Run the page in a host process (page-host.mjs), stopping it after
278
+ * `timeoutMs`. The host runs the page in a worker thread, and it exits when
279
+ * this process goes away.
280
+ * @param {{entry: string, root: string, served: ServedSet | null, withoutSegmenter: boolean}} input
227
281
  * @param {number} timeoutMs
228
- * @param {ServedSet | null} served
229
- * @returns {Promise<any>}
282
+ * @returns {Promise<any>} the page's answer, or `{crashed: how}` when a
283
+ * native crash ended the host
230
284
  */
231
- function runWorker(entry, root, timeoutMs, served) {
285
+ function runWorker(input, timeoutMs) {
232
286
  return new Promise((resolve, reject) => {
233
- const worker = new Worker(new URL('./page-worker.mjs', import.meta.url), {
234
- workerData: {entry, root, served: served ? {dir: served.dir, integrations: [...served.integrations]} : null},
287
+ const data = {
288
+ ...input,
289
+ served: input.served ? {dir: input.served.dir, integrations: [...input.served.integrations]} : null,
290
+ timeoutMs,
291
+ };
292
+ const host = fork(fileURLToPath(new URL('./page-host.mjs', import.meta.url)), [JSON.stringify(data)], {
293
+ // Never the CLI's own flags (such as --input-type).
235
294
  execArgv: [],
236
- stdout: true,
237
- stderr: true,
238
- resourceLimits: {maxOldGenerationSizeMb: 4096},
295
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
296
+ serialization: 'advanced',
239
297
  });
240
- // The page's own writes to stdout/stderr never reach the CLI's output.
241
- worker.stdout.resume();
242
- worker.stderr.resume();
243
298
  let settled = false;
244
- const timer = setTimeout(() => {
299
+ /** @param {() => void} settle */
300
+ const finish = settle => {
245
301
  if (settled) return;
246
302
  settled = true;
247
- void worker.terminate();
248
- reject(
249
- new RenderError(
250
- 'ERR_RENDER_TIMEOUT',
251
- `The page did not finish rendering within ${timeoutMs / 1000} seconds, so it was stopped. Look for a loop that never ends or a promise the page waits on.`,
252
- {seconds: timeoutMs / 1000},
303
+ clearTimeout(timer);
304
+ host.kill('SIGKILL');
305
+ settle();
306
+ };
307
+ const timer = setTimeout(() => {
308
+ finish(() =>
309
+ reject(
310
+ new RenderError(
311
+ 'ERR_RENDER_TIMEOUT',
312
+ `The page did not finish rendering within ${timeoutMs / 1000} seconds, so it was stopped. Look for a loop that never ends or a promise the page waits on.`,
313
+ {seconds: timeoutMs / 1000},
314
+ ),
253
315
  ),
254
316
  );
255
317
  }, timeoutMs);
256
- worker.once('message', message => {
257
- if (settled) return;
258
- settled = true;
259
- clearTimeout(timer);
260
- void worker.terminate();
261
- resolve(message);
318
+ host.once('message', (/** @type {any} */ message) => {
319
+ finish(() => {
320
+ if (message?.hostError !== undefined) {
321
+ const text = String(message.hostError);
322
+ reject(
323
+ /ERR_WORKER_OUT_OF_MEMORY|heap out of memory/.test(text)
324
+ ? new RenderError('ERR_RENDER_FAILED', 'The page ran out of memory while rendering.')
325
+ : new RenderError('ERR_RENDER_FAILED', `The page could not be rendered: ${text.split('\n')[0]}`),
326
+ );
327
+ } else if (message?.hostExit !== undefined) {
328
+ reject(new RenderError('ERR_RENDER_FAILED', `The page stopped rendering without an answer (exit ${message.hostExit}).`));
329
+ } else {
330
+ resolve(message);
331
+ }
332
+ });
262
333
  });
263
- worker.once('error', (/** @type {any} */ error) => {
264
- if (settled) return;
265
- settled = true;
266
- clearTimeout(timer);
267
- const out =
268
- /ERR_WORKER_OUT_OF_MEMORY|heap out of memory/.test(String(error?.message ?? error))
269
- ? new RenderError('ERR_RENDER_FAILED', 'The page ran out of memory while rendering.')
270
- : new RenderError('ERR_RENDER_FAILED', `The page could not be rendered: ${String(error?.message ?? error).split('\n')[0]}`);
271
- reject(out);
334
+ host.once('error', error => {
335
+ finish(() => reject(new RenderError('ERR_RENDER_FAILED', `The page could not be rendered: ${String(error?.message ?? error).split('\n')[0]}`)));
272
336
  });
273
- worker.once('exit', code => {
274
- if (settled) return;
275
- settled = true;
276
- clearTimeout(timer);
277
- reject(new RenderError('ERR_RENDER_FAILED', `The page stopped rendering without an answer (exit ${code}).`));
337
+ host.once('exit', (code, signal) => {
338
+ finish(() => {
339
+ const crash = crashOf(code, signal);
340
+ if (crash) resolve({crashed: crash});
341
+ else if (signal) reject(crashError(signal, {crashed: false, withoutSegmenter: input.withoutSegmenter}));
342
+ else reject(new RenderError('ERR_RENDER_FAILED', `The page stopped rendering without an answer (exit ${code}).`));
343
+ });
278
344
  });
279
345
  });
280
346
  }
281
347
 
348
+ /**
349
+ * Run the page. When this Node crashes in Intl.Segmenter, run it once more
350
+ * without Intl.Segmenter and say so in a warning.
351
+ * @param {{entry: string, root: string, served: ServedSet | null}} input
352
+ * @param {number} timeoutMs
353
+ * @param {RenderWarning[]} warnings
354
+ */
355
+ async function runPage(input, timeoutMs, warnings) {
356
+ const started = Date.now();
357
+ const first = await runWorker({...input, withoutSegmenter: false}, timeoutMs);
358
+ if (!first.crashed) return first;
359
+ if (!segmenterCrashes()) throw crashError(first.crashed, {crashed: true, withoutSegmenter: false});
360
+ const again = await runWorker({...input, withoutSegmenter: true}, Math.max(1000, timeoutMs - (Date.now() - started)));
361
+ if (again.crashed) throw crashError(again.crashed, {crashed: true, withoutSegmenter: true});
362
+ warnings.push(segmenterWarning());
363
+ return again;
364
+ }
365
+
366
+ /**
367
+ * The warning for a page rendered without Intl.Segmenter.
368
+ * @returns {RenderWarning}
369
+ */
370
+ export function segmenterWarning() {
371
+ return {
372
+ code: 'node-crash',
373
+ message: `This Node build (${process.version} at ${process.execPath}) crashes in Intl.Segmenter, so the page rendered without it, and the output may differ slightly: initials and character counts (such as Avatar initials) can split a joined emoji, a flag, or a letter written with a separate accent mark. Render with an official Node.js build to remove the difference.`,
374
+ };
375
+ }
376
+
282
377
  /**
283
378
  * Inline local images (src, srcset, poster and CSS url()) as data: URIs. A
284
379
  * link (`href`) loads nothing, so it is left alone.
@@ -370,9 +465,9 @@ export async function renderPage(entry, options = {}) {
370
465
  const dir = path.dirname(entry);
371
466
  const root = projectRootOf(dir);
372
467
  const served = options.served ?? null;
373
- const message = await runWorker(entry, root, options.timeoutMs ?? RENDER_TIMEOUT_MS, served);
374
468
  /** @type {RenderWarning[]} */
375
469
  const warnings = [];
470
+ const message = await runPage({entry, root, served}, options.timeoutMs ?? RENDER_TIMEOUT_MS, warnings);
376
471
  if (!message.ok) {
377
472
  throw new RenderError(message.error.code, message.error.message, message.error.detail);
378
473
  }
@@ -522,15 +617,30 @@ export async function renderPage(entry, options = {}) {
522
617
  message: `<${t.component}> on line ${t.line} did not render the text "${t.text.length > 60 ? `${t.text.slice(0, 57)}...` : t.text}" it was given; it takes that text through a prop (often label=), so check its props.`,
523
618
  });
524
619
  }
525
- for (const f of /** @type {string[]} */ (message.files ?? [])) {
526
- const src = fs.readFileSync(f, 'utf8');
527
- if (/\bResponsiveContainer\b/.test(src)) {
528
- warnings.push({
529
- code: 'chart-size',
530
- message: `${path.basename(f)} sizes a chart with ResponsiveContainer, which has no size until a browser measures it, so the chart is empty here; give the chart a fixed width and height.`,
531
- });
532
- break;
533
- }
620
+ // A chart the render did not draw is an empty box: a ResponsiveContainer
621
+ // holds only its sizing box, and a chart of fixed size an empty wrapper.
622
+ const blankCharts =
623
+ (markup.match(/<div class="recharts-responsive-container[" ][^>]*><div[^>]*><\/div><\/div>/g) ?? []).length +
624
+ (markup.match(/<div\b[^>]*\bclass="recharts-wrapper"[^>]*><\/div>/g) ?? []).length +
625
+ (message.chartsFailed ?? 0);
626
+ if (blankCharts > 0) {
627
+ warnings.push({
628
+ code: 'chart-size',
629
+ message: `${blankCharts === 1 ? 'A chart' : `${blankCharts} charts`} could not be drawn without a browser, so ${blankCharts === 1 ? 'it is' : 'they are'} empty here: the render draws a chart only when a module of the page (not an installed package) imports the chart, and its ResponsiveContainer, from recharts by name (\`import {LineChart} from 'recharts'\`).`,
630
+ });
631
+ }
632
+ if (message.chartsAlone) {
633
+ /** @type {Record<string, string>} */
634
+ const causes = {
635
+ changed: 'The page changed its charts once its effects ran (their data or props, or which charts it shows)',
636
+ rerendered: 'A component that makes a chart rendered again once the page\'s effects ran',
637
+ error: 'The page could not be drawn again without a browser',
638
+ };
639
+ const why = causes[message.chartsAlone] ?? causes.error;
640
+ warnings.push({
641
+ code: 'chart-size',
642
+ message: `${why}, so each chart was drawn by itself, as the first render gave it: a custom tick, label or shape in a chart sees React context defaults, not the values the page provides.`,
643
+ });
534
644
  }
535
645
  for (const sheet of /** @type {string[]} */ (message.skippedCss ?? [])) {
536
646
  warnings.push({
@@ -600,7 +710,7 @@ export async function renderPage(entry, options = {}) {
600
710
  '<meta name="color-scheme" content="light dark">' +
601
711
  `<meta name="generator" content="Astryx${options.version ? ` ${escapeHtml(options.version)}` : ''}">` +
602
712
  `<title>${escapeHtml(title)}</title>` +
603
- `<style>${SCHEME_CSS}${css}</style></head><body>${markup}` +
713
+ `<style>${SCHEME_CSS}${markup.includes('class="recharts-responsive-container"') ? CHART_CSS : ''}${css}</style></head><body>${markup}` +
604
714
  `${sourceBlock(path.basename(entry), embedded)}</body></html>\n`;
605
715
  return {
606
716
  html,
@@ -0,0 +1,63 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * The path patterns of the config in `dir` (tsconfig.json, else
6
+ * jsconfig.json), or none. Comments and trailing commas are allowed.
7
+ * @param {string} dir
8
+ * @returns {PathPattern[] | null} null when neither file is there
9
+ */
10
+ export function readPathsIn(dir: string): PathPattern[] | null;
11
+ /**
12
+ * The pattern a specifier matches by TypeScript's rules, and what its `*`
13
+ * matched: an exact key first, else the wildcard key with the longest prefix.
14
+ * @param {PathPattern[]} patterns
15
+ * @param {string} spec
16
+ * @returns {{pattern: PathPattern, capture: string} | null}
17
+ */
18
+ export function matchPath(patterns: PathPattern[], spec: string): {
19
+ pattern: PathPattern;
20
+ capture: string;
21
+ } | null;
22
+ /**
23
+ * The file a candidate path stands for: itself, with an extension, or its
24
+ * folder's index; null when none exists.
25
+ * @param {string} candidate
26
+ * @returns {string | null}
27
+ */
28
+ export function existingFile(candidate: string): string | null;
29
+ /**
30
+ * Resolve a specifier through path patterns: the first target that names an
31
+ * existing file wins.
32
+ * @param {PathPattern[]} patterns
33
+ * @param {string} spec
34
+ * @returns {string | null} the file, or null when the specifier matches no
35
+ * pattern or no target exists
36
+ */
37
+ export function resolvePath(patterns: PathPattern[], spec: string): string | null;
38
+ export function candidatesOf(match: {
39
+ pattern: PathPattern;
40
+ capture: string;
41
+ }): string[];
42
+ export type PathPattern = {
43
+ /**
44
+ * - The key as written.
45
+ */
46
+ key: string;
47
+ /**
48
+ * - What comes before the key's `*` (all of it, for an exact key).
49
+ */
50
+ prefix: string;
51
+ /**
52
+ * - What comes after the key's `*`.
53
+ */
54
+ suffix: string;
55
+ /**
56
+ * - Whether the key holds a `*`.
57
+ */
58
+ wildcard: boolean;
59
+ /**
60
+ * - Absolute target patterns, in order.
61
+ */
62
+ targets: string[];
63
+ };
@@ -0,0 +1,138 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A project's `compilerOptions.paths` (tsconfig.json or jsconfig.json),
5
+ * read and matched by TypeScript's rules, for `astryx experimental render`:
6
+ * an exact key matches only itself; a key may hold one `*` anywhere, and the
7
+ * longest matching prefix wins; a target may hold one `*`, which takes what
8
+ * the key's `*` matched; targets are tried in order, and when none exists the
9
+ * import resolves as a package. Only bare specifiers are matched. Targets
10
+ * resolve from `baseUrl` (default: the config's folder).
11
+ *
12
+ * @position foundation/render — used by page-worker.mjs and embedded-imports.mjs
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+
18
+ /**
19
+ * @typedef {object} PathPattern
20
+ * @property {string} key - The key as written.
21
+ * @property {string} prefix - What comes before the key's `*` (all of it, for an exact key).
22
+ * @property {string} suffix - What comes after the key's `*`.
23
+ * @property {boolean} wildcard - Whether the key holds a `*`.
24
+ * @property {string[]} targets - Absolute target patterns, in order.
25
+ */
26
+
27
+ /** Extensions an extensionless candidate may stand for, in resolution order. */
28
+ const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.mts', '.cts', '.json'];
29
+
30
+ /**
31
+ * The path patterns of the config in `dir` (tsconfig.json, else
32
+ * jsconfig.json), or none. Comments and trailing commas are allowed.
33
+ * @param {string} dir
34
+ * @returns {PathPattern[] | null} null when neither file is there
35
+ */
36
+ export function readPathsIn(dir) {
37
+ for (const name of ['tsconfig.json', 'jsconfig.json']) {
38
+ const file = path.join(dir, name);
39
+ if (!fs.existsSync(file)) continue;
40
+ try {
41
+ const text = fs
42
+ .readFileSync(file, 'utf8')
43
+ .replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (_, str) => str ?? '')
44
+ .replace(/,(\s*[}\]])/g, '$1');
45
+ const options = JSON.parse(text)?.compilerOptions ?? {};
46
+ const base = path.resolve(dir, typeof options.baseUrl === 'string' ? options.baseUrl : '.');
47
+ /** @type {PathPattern[]} */
48
+ const out = [];
49
+ for (const [key, targets] of Object.entries(options.paths ?? {})) {
50
+ if (!Array.isArray(targets)) continue;
51
+ const star = key.indexOf('*');
52
+ if (star !== key.lastIndexOf('*')) continue;
53
+ out.push({
54
+ key,
55
+ prefix: star === -1 ? key : key.slice(0, star),
56
+ suffix: star === -1 ? '' : key.slice(star + 1),
57
+ wildcard: star !== -1,
58
+ targets: targets
59
+ .filter(t => typeof t === 'string' && t.indexOf('*') === t.lastIndexOf('*'))
60
+ .map(t => path.resolve(base, t)),
61
+ });
62
+ }
63
+ return out;
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * The pattern a specifier matches by TypeScript's rules, and what its `*`
73
+ * matched: an exact key first, else the wildcard key with the longest prefix.
74
+ * @param {PathPattern[]} patterns
75
+ * @param {string} spec
76
+ * @returns {{pattern: PathPattern, capture: string} | null}
77
+ */
78
+ export function matchPath(patterns, spec) {
79
+ // TypeScript applies `paths` only to bare specifiers.
80
+ if (/^\.{1,2}(\/|$)|^\//.test(spec)) return null;
81
+ const exact = patterns.find(p => !p.wildcard && p.key === spec);
82
+ if (exact) return {pattern: exact, capture: ''};
83
+ /** @type {{pattern: PathPattern, capture: string} | null} */
84
+ let best = null;
85
+ for (const p of patterns) {
86
+ if (!p.wildcard) continue;
87
+ if (spec.length < p.prefix.length + p.suffix.length) continue;
88
+ if (!spec.startsWith(p.prefix) || !spec.endsWith(p.suffix)) continue;
89
+ if (best && best.pattern.prefix.length >= p.prefix.length) continue;
90
+ best = {pattern: p, capture: spec.slice(p.prefix.length, spec.length - p.suffix.length)};
91
+ }
92
+ return best;
93
+ }
94
+
95
+ /**
96
+ * The absolute paths a match names, one per target, in order.
97
+ * @param {{pattern: PathPattern, capture: string}} match
98
+ * @returns {string[]}
99
+ */
100
+ export const candidatesOf = match => match.pattern.targets.map(t => t.replace('*', match.capture));
101
+
102
+ /**
103
+ * The file a candidate path stands for: itself, with an extension, or its
104
+ * folder's index; null when none exists.
105
+ * @param {string} candidate
106
+ * @returns {string | null}
107
+ */
108
+ export function existingFile(candidate) {
109
+ const isFile = (/** @type {string} */ f) => {
110
+ try {
111
+ return fs.statSync(f).isFile();
112
+ } catch {
113
+ return false;
114
+ }
115
+ };
116
+ if (isFile(candidate)) return candidate;
117
+ for (const ext of EXTENSIONS) if (isFile(candidate + ext)) return candidate + ext;
118
+ for (const ext of EXTENSIONS) if (isFile(path.join(candidate, `index${ext}`))) return path.join(candidate, `index${ext}`);
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Resolve a specifier through path patterns: the first target that names an
124
+ * existing file wins.
125
+ * @param {PathPattern[]} patterns
126
+ * @param {string} spec
127
+ * @returns {string | null} the file, or null when the specifier matches no
128
+ * pattern or no target exists
129
+ */
130
+ export function resolvePath(patterns, spec) {
131
+ const match = matchPath(patterns, spec);
132
+ if (!match) return null;
133
+ for (const candidate of candidatesOf(match)) {
134
+ const file = existingFile(candidate);
135
+ if (file) return file;
136
+ }
137
+ return null;
138
+ }
@@ -332,19 +332,19 @@ export const doc = {
332
332
  value: 'ERR_RENDER_FAILED',
333
333
  stability: 'experimental',
334
334
  description:
335
- 'The page threw while rendering. Nothing was written. Raised only by the experimental render command.',
335
+ 'The page threw while rendering, or Node crashed while it rendered the page. Nothing was written. Raised only by the experimental render command.',
336
336
  },
337
337
  {
338
338
  value: 'ERR_RENDER_NOT_ASTRYX',
339
339
  stability: 'experimental',
340
340
  description:
341
- '`render --reverse` was given a file with no embedded source and no Astryx markup. Nothing was written. Raised only by the experimental render command.',
341
+ '`render --reverse` was given a file that a render did not write, with no embedded source and no Astryx markup. Nothing was written. Raised only by the experimental render command.',
342
342
  },
343
343
  {
344
344
  value: 'ERR_RENDER_SOURCE_DAMAGED',
345
345
  stability: 'experimental',
346
346
  description:
347
- '`render --reverse` refused the received file, and nothing was written: its embedded source is damaged (not valid, or a file does not match its checksum), lists a path twice, carries a module, stylesheet or JSON file the page does not reach through its own imports, or an image, font or media file whose bytes are not one, or carries a file a render never embeds: a dotfile or dot-folder, anything under node_modules, a package manifest, lockfile or tool configuration, a file a test, story or benchmark runner would run, or any kind but a page module, stylesheet, JSON file or image. Raised only by the experimental render command.',
347
+ '`render --reverse` refused the received file, and nothing was written: its embedded source was removed (a file a render wrote, with no Astryx components left to rebuild from) or is damaged (not valid, or a file does not match its checksum), lists a path twice, carries a module, stylesheet or JSON file the page does not reach through its own imports, or an image, font or media file whose bytes are not one, or carries a file a render never embeds: a dotfile or dot-folder, anything under node_modules, a package manifest, lockfile or tool configuration, a file a test, story or benchmark runner would run, or any kind but a page module, stylesheet, JSON file or image. Raised only by the experimental render command.',
348
348
  },
349
349
  ],
350
350
  };
@@ -298,8 +298,9 @@ export const ERROR_CODES = Object.freeze({
298
298
  */
299
299
  ERR_RENDER_FAILED: 'ERR_RENDER_FAILED',
300
300
  /**
301
- * `render --reverse` was given an HTML file that holds no Astryx markup and
302
- * no embedded source, so there is nothing to rebuild. Nothing was written.
301
+ * `render --reverse` was given an HTML file that a render did not write and
302
+ * that holds no Astryx markup and no embedded source, so there is nothing
303
+ * to rebuild. Nothing was written.
303
304
  */
304
305
  ERR_RENDER_NOT_ASTRYX: 'ERR_RENDER_NOT_ASTRYX',
305
306
  /**
@@ -19,6 +19,11 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a: string, b: string, max?: number): number;
27
+ export function levenshteinDistance(a: string, b: string, max?: number, { swaps }?: {
28
+ swaps?: boolean;
29
+ }): number;
@@ -19,11 +19,15 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a, b, max) {
25
- if (max !== undefined) return distanceWithin(a, b, max);
26
- const m = a.length, n = b.length;
27
+ export function levenshteinDistance(a, b, max, {swaps = false} = {}) {
28
+ if (max !== undefined) return distanceWithin(a, b, max, swaps);
29
+ const m = a.length,
30
+ n = b.length;
27
31
  // Early exit: the distance is always ≥ |m − n|, so once the length gap
28
32
  // exceeds the loosest fuzzy-match threshold any caller uses (the hook
29
33
  // suggester keeps distance ≤ 5) the pair can never match — skip the O(m·n)
@@ -36,9 +40,10 @@ export function levenshteinDistance(a, b, max) {
36
40
  for (let j = 0; j <= n; j++) dp[0][j] = j;
37
41
  for (let i = 1; i <= m; i++) {
38
42
  for (let j = 1; j <= n; j++) {
39
- dp[i][j] = a[i-1] === b[j-1]
40
- ? dp[i-1][j-1]
41
- : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
43
+ dp[i][j] =
44
+ a[i - 1] === b[j - 1]
45
+ ? dp[i - 1][j - 1]
46
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
42
47
  }
43
48
  }
44
49
  return dp[m][n];
@@ -51,11 +56,13 @@ export function levenshteinDistance(a, b, max) {
51
56
  * @param {string} a
52
57
  * @param {string} b
53
58
  * @param {number} max
59
+ * @param {boolean} swaps - two adjacent letters swapped cost one edit
54
60
  * @returns {number}
55
61
  */
56
- function distanceWithin(a, b, max) {
62
+ function distanceWithin(a, b, max, swaps) {
57
63
  const over = max + 1;
58
64
  if (Math.abs(a.length - b.length) > max) return over;
65
+ let before = new Array(b.length + 1).fill(over);
59
66
  let previous = new Array(b.length + 1).fill(over);
60
67
  let current = new Array(b.length + 1).fill(over);
61
68
  for (let j = 0; j <= Math.min(b.length, max); j++) previous[j] = j;
@@ -67,18 +74,27 @@ function distanceWithin(a, b, max) {
67
74
  let rowMin = current[0];
68
75
  for (let j = from; j <= to; j++) {
69
76
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
70
- const value = Math.min(
77
+ let value = Math.min(
71
78
  previous[j - 1] + cost,
72
79
  previous[j] + 1,
73
80
  current[j - 1] + 1,
74
81
  over,
75
82
  );
83
+ if (
84
+ swaps &&
85
+ i > 1 &&
86
+ j > 1 &&
87
+ a[i - 1] === b[j - 2] &&
88
+ a[i - 2] === b[j - 1]
89
+ ) {
90
+ value = Math.min(value, before[j - 2] + 1);
91
+ }
76
92
  current[j] = value;
77
93
  if (value < rowMin) rowMin = value;
78
94
  }
79
95
  // Every later cell costs at least this row's least.
80
96
  if (rowMin >= over) return over;
81
- [previous, current] = [current, previous];
97
+ [before, previous, current] = [previous, current, before];
82
98
  }
83
99
  return Math.min(previous[b.length], over);
84
100
  }
@@ -18,15 +18,22 @@ export function nameWords(text: string): string[];
18
18
  * when it carries some of them, the items that carry none are left out. When
19
19
  * no item carries one, nothing is left out. The list only ever gets shorter,
20
20
  * and its order does not change.
21
+ *
22
+ * With `typos`, a near name (one or two slips from the typed name, as
23
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
24
+ * that carry every typed word and above the rest. With `closest` too, near
25
+ * names stay in the list whatever the cut leaves out, and the list is never
26
+ * longer than it is without `typos`.
21
27
  * @template T
22
28
  * @param {string} typedName
23
29
  * @param {T[]} items
24
30
  * @param {(item: T) => Array<string | undefined>} namesOf
25
- * @param {{closest?: boolean}} [options]
31
+ * @param {{closest?: boolean, typos?: boolean}} [options]
26
32
  * @returns {T[]}
27
33
  */
28
34
  export function orderByAffinity<T>(typedName: string, items: T[], namesOf: (item: T) => Array<string | undefined>, options?: {
29
35
  closest?: boolean;
36
+ typos?: boolean;
30
37
  }): T[];
31
38
  /**
32
39
  * Docs route suggestions, closest to the typed route first. When every