@astryxdesign/cli 0.7.12-canary.f36bd72 → 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 (50) 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/doctor/doctor.d.mts +4 -4
  5. package/api/doctor/doctor.mjs +248 -78
  6. package/api/icon/_authoring.mjs +8 -1
  7. package/api/icon/add/add.mjs +45 -5
  8. package/api/icon/icon.type.d.mts +24 -0
  9. package/api/icon/icon.type.mjs +6 -0
  10. package/api/icon/iconAdd.doc.mjs +1 -1
  11. package/api/icon/map/map.mjs +11 -4
  12. package/api/integration/add-helpers.d.mts +5 -1
  13. package/api/integration/add-helpers.mjs +1 -1
  14. package/api/render/render.doc.mjs +1 -1
  15. package/api/render/render.type.d.mts +1 -1
  16. package/api/render/render.type.mjs +1 -1
  17. package/api/search/ORDERING.md +3 -0
  18. package/api/search/_adapter.d.mts +10 -2
  19. package/api/search/_adapter.mjs +13 -2
  20. package/api/search/_fit-order.d.mts +47 -0
  21. package/api/search/_fit-order.mjs +257 -0
  22. package/api/search/_main-searcher.mjs +15 -5
  23. package/api/template/skeleton/skeleton.mjs +1 -1
  24. package/assets/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +1 -1
  25. package/assets/codemods/transforms/v0.1.0/index.mjs +1 -1
  26. package/assets/docs/tree/icon-sets.doc.mjs +4 -4
  27. package/assets/docs/tree/render-pages.doc.mjs +1 -1
  28. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +2 -2
  29. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +13 -7
  30. package/assets/templates/pages/table-grouped/page.tsx +30 -30
  31. package/assets/templates/themes/gothic/gothicTheme.ts +16 -0
  32. package/assets/templates/themes/matcha/matchaTheme.ts +1 -1
  33. package/assets/templates/themes/neutral/neutralTheme.ts +6 -0
  34. package/assets/templates/themes/y2k/y2kPaletteRefs.generated.ts +1 -1
  35. package/assets/templates/themes/y2k/y2kTheme.ts +1 -1
  36. package/clients/cli/commands/experimental/icon.mjs +25 -4
  37. package/clients/cli/commands/experimental-render.doc.mjs +1 -1
  38. package/clients/cli/commands/template.mjs +204 -181
  39. package/foundation/discovery/component-discovery.mjs +1 -1
  40. package/foundation/discovery/icon-set-writer.d.mts +21 -5
  41. package/foundation/discovery/icon-set-writer.mjs +114 -13
  42. package/foundation/discovery/template-adapter.mjs +2 -2
  43. package/foundation/render/page-host.d.mts +4 -0
  44. package/foundation/render/page-host.mjs +45 -0
  45. package/foundation/render/page-worker.mjs +11 -5
  46. package/foundation/render/render-page.d.mts +11 -6
  47. package/foundation/render/render-page.mjs +138 -44
  48. package/foundation/response/error-codes.doc.mjs +1 -1
  49. package/foundation/xle/registry-core.mjs +1 -1
  50. package/package.json +6 -6
@@ -48,6 +48,7 @@ import {
48
48
  exportValueKind,
49
49
  followedExportNames,
50
50
  moduleExportOrigin,
51
+ staticExportBindings,
51
52
  } from './module-exports.mjs';
52
53
 
53
54
  /** @typedef {import('./icon-set-discovery.mjs').DiscoveredIconSet} DiscoveredIconSet */
@@ -100,10 +101,19 @@ export function iconIdOf(name) {
100
101
 
101
102
  /**
102
103
  * The name an icon set exports an icon as: PascalCase ending in `Icon`
103
- * (`Trash2` → `Trash2Icon`; `TrashIcon` stays).
104
+ * (`Trash2` → `Trash2Icon`; `TrashIcon` stays). A JavaScript name cannot
105
+ * start with a digit, so a name that starts with one is refused.
106
+ * {@link iconNaming} keeps a library prefix to prevent this (`fa0` →
107
+ * `Fa0Icon`).
104
108
  * @param {string} name
105
109
  */
106
110
  export function iconExportName(name) {
111
+ if (/^[0-9]/u.test(name)) {
112
+ throw new IconSetAuthoringError(
113
+ `The icon id "${iconIdOf(name)}" starts with a digit, and an export name cannot. Pass --id with an id that starts with a letter.`,
114
+ 'ERR_INVALID_ARGUMENT',
115
+ );
116
+ }
107
117
  const pascal = name[0].toUpperCase() + name.slice(1);
108
118
  return pascal.endsWith('Icon') ? pascal : `${pascal}Icon`;
109
119
  }
@@ -146,7 +156,11 @@ function packageDirOf(cwd) {
146
156
  * `selector` names, else the only set.
147
157
  * @param {string} cwd
148
158
  * @param {string} [selector] a set name
149
- * @returns {Promise<{packageDir: string, pkg: Record<string, any>, root: string, set: DiscoveredIconSet, sets: DiscoveredIconSet[]}>}
159
+ * The set's `module` names the package as package.json names it now;
160
+ * `moduleRenamed` says the data file still names an old name, so a command
161
+ * writes the data file even when nothing else changes; `moduleUpdate` gives
162
+ * the old and the new name for the receipt, else null.
163
+ * @returns {Promise<{packageDir: string, pkg: Record<string, any>, root: string, set: DiscoveredIconSet, sets: DiscoveredIconSet[], moduleRenamed: boolean, moduleUpdate: {previous: string, module: string} | null}>}
150
164
  */
151
165
  export async function findLocalIconSet(cwd, selector) {
152
166
  const packageDir = packageDirOf(cwd);
@@ -190,12 +204,20 @@ export async function findLocalIconSet(cwd, selector) {
190
204
  ? found.sets.filter(set => set.name === selector)
191
205
  : found.sets;
192
206
  if (named.length === 1) {
207
+ const set = {...named[0], packageDir};
208
+ const module = moduleInPackage(set.data.module, pkg.name);
209
+ const moduleUpdate =
210
+ module !== set.data.module ? {previous: set.data.module, module} : null;
211
+ const moduleRenamed = moduleUpdate != null;
212
+ if (moduleRenamed) set.data = {...set.data, module};
193
213
  return {
194
214
  packageDir,
195
215
  pkg,
196
216
  root,
197
- set: {...named[0], packageDir},
217
+ set,
198
218
  sets: found.sets,
219
+ moduleRenamed,
220
+ moduleUpdate,
199
221
  };
200
222
  }
201
223
  const broken = selector
@@ -229,6 +251,22 @@ export async function findLocalIconSet(cwd, selector) {
229
251
  );
230
252
  }
231
253
 
254
+ /**
255
+ * The data file's `module` in the package as it is named now. The data file
256
+ * belongs to the CLI, so after a package rename the next write gives the new
257
+ * name (`@acme/old/solid` → `@acme/new/solid`). A ./ path, or a package
258
+ * with no name, is left as it is.
259
+ * @param {string} module
260
+ * @param {unknown} name the package.json `name`
261
+ */
262
+ function moduleInPackage(module, name) {
263
+ if (typeof name !== 'string' || name === '' || module.startsWith('.')) {
264
+ return module;
265
+ }
266
+ const owner = iconModulePackage(module);
267
+ return owner === name ? module : `${name}${module.slice(owner.length)}`;
268
+ }
269
+
232
270
  /** The data file's fields, in the order the file is written. */
233
271
  const DATA_KEYS = [
234
272
  'schemaVersion',
@@ -599,7 +637,7 @@ const SCREAMING_CASE_RE = /^[A-Z][A-Z0-9_]+$/u;
599
637
  * set declares an adapter, also data exports, camelCase included: objects,
600
638
  * and plain strings only in a library whose candidates are all strings.
601
639
  * Left out: `default`, `Icon`, `…Provider` and `…Context` components, SCREAMING_CASE constants, number and boolean
602
- * exports, and `exclude`.
640
+ * exports, packs of other exports ({@link isIconPack}), and `exclude`.
603
641
  * @param {string} file
604
642
  * @param {{adapter?: boolean, exclude?: string[]}} [options] `exclude`: export
605
643
  * names that are not icons
@@ -609,7 +647,9 @@ function iconCandidates(file, {adapter = false, exclude = []} = {}) {
609
647
  const skipped = new Set(exclude);
610
648
  /** @type {Array<{name: string, adapt: boolean, scalar: string | null}>} */
611
649
  const out = [];
612
- for (const name of followedExportNames(file)) {
650
+ const names = followedExportNames(file);
651
+ const exported = new Set(names);
652
+ for (const name of names) {
613
653
  if (name === 'default' || name === 'Icon' || skipped.has(name)) continue;
614
654
  // A context provider or context (`LucideProvider`) is a component that
615
655
  // draws nothing.
@@ -622,7 +662,8 @@ function iconCandidates(file, {adapter = false, exclude = []} = {}) {
622
662
  } else if (
623
663
  adapter &&
624
664
  kind === 'data' &&
625
- /^[A-Za-z][A-Za-z0-9]*$/u.test(name)
665
+ /^[A-Za-z][A-Za-z0-9]*$/u.test(name) &&
666
+ !isIconPack(file, name, exported)
626
667
  ) {
627
668
  const scalar = exportScalarType(file, name);
628
669
  // A number or a boolean draws nothing; a string may be SVG markup.
@@ -637,6 +678,20 @@ function iconCandidates(file, {adapter = false, exclude = []} = {}) {
637
678
  : out;
638
679
  }
639
680
 
681
+ /**
682
+ * Whether a data export is a pack: an object whose keys are all other
683
+ * exports of the module, such as `fas` in a library that also exports
684
+ * `faXmark` and `faHouse`. A pack holds icons but draws nothing.
685
+ * @param {string} file
686
+ * @param {string} name
687
+ * @param {Set<string>} exported every export name of the module
688
+ */
689
+ function isIconPack(file, name, exported) {
690
+ const bindings = staticExportBindings(file, name);
691
+ if (bindings == null || bindings.size < 2) return false;
692
+ return [...bindings.keys()].every(key => key !== name && exported.has(key));
693
+ }
694
+
640
695
  /**
641
696
  * The exports of a library that are not icons and that wrapping it leaves
642
697
  * out (`Icon`, `LucideProvider`, `createLucideIcon`, constants), so the
@@ -690,7 +745,9 @@ export function libraryPrefix(names) {
690
745
 
691
746
  /**
692
747
  * An export's id and the name the set exports it as, with the library prefix
693
- * left out: `featherEye` with prefix `feather` is id `eye`, `EyeIcon`.
748
+ * left out: `featherEye` with prefix `feather` is id `eye`, `EyeIcon`. A
749
+ * name that would start with a digit keeps the prefix (`fa500px` is
750
+ * `fa-500px`, `Fa500pxIcon`).
694
751
  * @param {string} name
695
752
  * @param {string[]} prefix
696
753
  * @returns {{id: string, name: string}}
@@ -698,9 +755,12 @@ export function libraryPrefix(names) {
698
755
  export function iconNaming(name, prefix) {
699
756
  const all = iconIdOf(name).split('-');
700
757
  // Only a name that starts with the prefix, and has more after it, loses it.
758
+ // A name that would then start with a digit keeps the prefix: `fa0` is
759
+ // `fa-0`, `Fa0Icon`.
701
760
  const strip =
702
761
  all.length > prefix.length &&
703
- prefix.every((word, index) => all[index] === word);
762
+ prefix.every((word, index) => all[index] === word) &&
763
+ !/^[0-9]/u.test(all[prefix.length]);
704
764
  if (!strip) prefix = [];
705
765
  const words = all.slice(prefix.length);
706
766
  const id = words.join('-');
@@ -1423,10 +1483,39 @@ function terminated(statement) {
1423
1483
  : `${statement};`;
1424
1484
  }
1425
1485
 
1486
+ /** Syntax-tree fields that only say how a file is laid out. */
1487
+ const LAYOUT_KEYS = new Set([
1488
+ 'start',
1489
+ 'end',
1490
+ 'loc',
1491
+ 'range',
1492
+ 'extra',
1493
+ 'leadingComments',
1494
+ 'trailingComments',
1495
+ 'innerComments',
1496
+ ]);
1497
+
1498
+ /**
1499
+ * The top-level statements of a module as syntax trees without positions,
1500
+ * comments, or raw text, one string each. A formatter changes line breaks,
1501
+ * quotes, semicolons, trailing commas, and the wrapping of long type
1502
+ * arguments; none of these change the tree.
1503
+ * @param {string} text
1504
+ * @returns {string[]}
1505
+ */
1506
+ function statementTrees(text) {
1507
+ const ast = parse(text, {sourceType: 'module', plugins: ['typescript']});
1508
+ return ast.program.body.map(node =>
1509
+ JSON.stringify(node, (key, value) =>
1510
+ LAYOUT_KEYS.has(key) ? undefined : value,
1511
+ ),
1512
+ );
1513
+ }
1514
+
1426
1515
  /**
1427
1516
  * Whether a generated declaration file declares what its generated module
1428
1517
  * gives, statement by statement however a formatter left either file
1429
- * ({@link generatedStatements}); the header comment is ignored.
1518
+ * ({@link statementTrees}); the header comment is ignored.
1430
1519
  * @param {string} actual the declaration file as it is
1431
1520
  * @param {string} expected {@link generatedTypesText} for the module
1432
1521
  * @param {string} label the declaration file's path, for a parse error
@@ -1434,11 +1523,19 @@ function terminated(statement) {
1434
1523
  export function generatedTypesMatch(actual, expected, label) {
1435
1524
  let have;
1436
1525
  try {
1437
- have = generatedStatements(actual, label);
1526
+ have = statementTrees(actual);
1438
1527
  } catch {
1439
1528
  return false;
1440
1529
  }
1441
- const want = generatedStatements(expected, label);
1530
+ let want;
1531
+ try {
1532
+ want = statementTrees(expected);
1533
+ } catch (error) {
1534
+ throw new IconSetAuthoringError(
1535
+ `The declarations written for ${label} do not parse (${error instanceof Error ? error.message : String(error)}).`,
1536
+ 'ERR_INVALID_ARGUMENT',
1537
+ );
1538
+ }
1442
1539
  return have.length === want.length && have.every((s, i) => s === want[i]);
1443
1540
  }
1444
1541
 
@@ -1817,10 +1914,11 @@ export function generatedNamesOf(packageDir, generated, from, source) {
1817
1914
  * differently; and a refresh changes only the icons its module supplies.
1818
1915
  * @param {string} packageDir
1819
1916
  * @param {string} generated
1820
- * @returns {Map<string, {module: string, source: string}>}
1917
+ * @returns {Map<string, {module: string, source: string, adapt: boolean}>}
1918
+ * `adapt`: the set's adapter turns the export into a component
1821
1919
  */
1822
1920
  export function generatedOrigins(packageDir, generated) {
1823
- /** @type {Map<string, {module: string, source: string}>} */
1921
+ /** @type {Map<string, {module: string, source: string, adapt: boolean}>} */
1824
1922
  const origins = new Map();
1825
1923
  const file = path.join(packageDir, generated);
1826
1924
  if (!fs.existsSync(file)) return origins;
@@ -1847,6 +1945,7 @@ export function generatedOrigins(packageDir, generated) {
1847
1945
  origins.set(reexport[2] ?? reexport[1], {
1848
1946
  module: fromRoot(reexport[3]),
1849
1947
  source: reexport[1],
1948
+ adapt: false,
1850
1949
  });
1851
1950
  continue;
1852
1951
  }
@@ -1857,6 +1956,7 @@ export function generatedOrigins(packageDir, generated) {
1857
1956
  origins.set(adapted[2], {
1858
1957
  module: fromRoot(adapted[3]),
1859
1958
  source: adapted[1],
1959
+ adapt: true,
1860
1960
  });
1861
1961
  continue;
1862
1962
  }
@@ -1867,6 +1967,7 @@ export function generatedOrigins(packageDir, generated) {
1867
1967
  origins.set(adaptedDefault[1], {
1868
1968
  module: fromRoot(adaptedDefault[2]),
1869
1969
  source: 'default',
1970
+ adapt: true,
1870
1971
  });
1871
1972
  }
1872
1973
  }
@@ -2100,8 +2100,8 @@ export function extractComponents(pagePath) {
2100
2100
  */
2101
2101
  export function componentsInSource(src) {
2102
2102
  // Match JSX opening tags, e.g. `<Section` or the legacy `<XDSSection`.
2103
- // Templates author bare component names post un-prefix migration
2104
- // (P2380608025), so the `XDS` prefix is optional. Anchoring on the `<`
2103
+ // Templates author bare component names post un-prefix migration,
2104
+ // so the `XDS` prefix is optional. Anchoring on the `<`
2105
2105
  // JSX-tag boundary keeps this precise (avoids matching imports/comments/
2106
2106
  // identifiers) while remaining prefix-agnostic.
2107
2107
  //
@@ -0,0 +1,4 @@
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
+ export {};
@@ -0,0 +1,45 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Host process for `astryx experimental render`: runs page-worker.mjs
5
+ * in a worker thread and sends its one answer to the CLI.
6
+ *
7
+ * A crash of Node itself ends only this process, never the CLI. The page runs
8
+ * in a thread, so this process can still act while the page loops: it exits
9
+ * when the CLI goes away (the IPC channel closes) or when its own time limit
10
+ * passes, so a page that never finishes never outlives the CLI.
11
+ *
12
+ * @input argv[2]: JSON {entry, root, served, withoutSegmenter, timeoutMs}
13
+ * @output one IPC message: the worker's answer, or {hostError: message}, or
14
+ * {hostExit: code}
15
+ * @position foundation/render — started by render-page.mjs
16
+ */
17
+
18
+ import {Worker} from 'node:worker_threads';
19
+
20
+ /** @type {{entry: string, root: string, served: {dir: string, integrations: string[]} | null, withoutSegmenter: boolean, timeoutMs: number}} */
21
+ const input = JSON.parse(process.argv[2] ?? '{}');
22
+
23
+ /** @param {unknown} message */
24
+ function answer(message) {
25
+ if (!process.connected) process.exit(0);
26
+ process.send?.(message, () => process.exit(0));
27
+ }
28
+
29
+ process.once('disconnect', () => process.exit(0));
30
+ // A margin past the CLI's own limit, so the CLI reports the timeout first.
31
+ setTimeout(() => process.exit(0), input.timeoutMs + 5000);
32
+
33
+ const worker = new Worker(new URL('./page-worker.mjs', import.meta.url), {
34
+ workerData: input,
35
+ execArgv: [],
36
+ stdout: true,
37
+ stderr: true,
38
+ resourceLimits: {maxOldGenerationSizeMb: 4096},
39
+ });
40
+ // The page's own writes to stdout/stderr never reach the CLI's output.
41
+ worker.stdout.resume();
42
+ worker.stderr.resume();
43
+ worker.once('message', answer);
44
+ worker.once('error', (/** @type {any} */ error) => answer({hostError: String(error?.message ?? error)}));
45
+ worker.once('exit', code => answer({hostExit: code}));
@@ -5,8 +5,10 @@
5
5
  * the project (TypeScript, JSX and StyleX compiled in memory) and renders its
6
6
  * default export to static markup with the project's own React.
7
7
  *
8
- * It runs in a worker thread so a page that never finishes rendering can be
9
- * stopped, and so nothing the page prints reaches the CLI's own output.
8
+ * It runs in a worker thread of a small host process (page-host.mjs), so a
9
+ * page that never finishes rendering can be stopped, nothing the page prints
10
+ * reaches the CLI's own output, and a crash of Node itself ends only the host,
11
+ * never the CLI.
10
12
  *
11
13
  * Outside a project, the Astryx packages the page's folder lacks resolve
12
14
  * from the package set the CLI runs from (served-packages.mjs), and React and
@@ -16,10 +18,10 @@
16
18
  * so the server render sets them aside and draws them after it with React
17
19
  * DOM in a small document (charts.mjs).
18
20
  *
19
- * @input workerData {entry, root, served}
21
+ * @input workerData {entry, root, served, withoutSegmenter}
20
22
  * @output one message: {ok: true, markup, css, files, texts, logs} or
21
23
  * {ok: false, error: {code, message, detail?}}
22
- * @position foundation/render — started by render-page.mjs
24
+ * @position foundation/render — started by page-host.mjs
23
25
  */
24
26
 
25
27
  import * as fs from 'node:fs';
@@ -38,9 +40,13 @@ import {CHART_GLOBAL, CHART_ROOTS, chartDocument, chartSize, markLegends} from '
38
40
 
39
41
  /** @typedef {{level: string, text: string}} PageLog */
40
42
 
41
- /** @type {{entry: string, root: string, served: {dir: string, integrations: string[]} | null}} */
43
+ /** @type {{entry: string, root: string, served: {dir: string, integrations: string[]} | null, withoutSegmenter?: boolean}} */
42
44
  const {entry, root} = workerData;
43
45
 
46
+ // Some Node builds crash in Intl.Segmenter. Without it, Core splits text by
47
+ // code points, so a joined emoji can count as several characters.
48
+ if (workerData.withoutSegmenter) delete (/** @type {any} */ (Intl)).Segmenter;
49
+
44
50
  /** The package set, for a page outside a project whose folder has no Core of its own. */
45
51
  const served = workerData.served && findNodeModulesPackage(path.dirname(entry), '@astryxdesign/core') === null ? workerData.served : null;
46
52
  const ROOT_PREFIX = root.endsWith(path.sep) ? root : root + path.sep;
@@ -1,6 +1,11 @@
1
1
  // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
2
  // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
3
 
4
+ /**
5
+ * The warning for a page rendered without Intl.Segmenter.
6
+ * @returns {RenderWarning}
7
+ */
8
+ export function segmenterWarning(): RenderWarning;
4
9
  /**
5
10
  * Render a page module to one self-contained HTML file.
6
11
  * @param {string} entry absolute path of the page module
@@ -20,7 +25,7 @@ export const RENDER_TIMEOUT_MS: 60000;
20
25
  export { PAGE_EXTENSIONS } from "./embedded-files.mjs";
21
26
  /**
22
27
  * @typedef {object} RenderWarning
23
- * @property {'dropped-children' | 'remote-asset' | 'missing-asset' | 'page-output' | 'page-script' | 'no-astryx-styles' | 'chart-size' | 'not-embedded'} code - What kind of problem.
28
+ * @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.
24
29
  * @property {string} message - What happened and what to do, in one sentence.
25
30
  */
26
31
  /**
@@ -46,15 +51,11 @@ export class RenderError extends Error {
46
51
  code: string;
47
52
  detail: Record<string, unknown> | undefined;
48
53
  }
49
- export type ServedSet = {
50
- dir: string;
51
- integrations: readonly string[];
52
- };
53
54
  export type RenderWarning = {
54
55
  /**
55
56
  * - What kind of problem.
56
57
  */
57
- code: "dropped-children" | "remote-asset" | "missing-asset" | "page-output" | "page-script" | "no-astryx-styles" | "chart-size" | "not-embedded";
58
+ code: "dropped-children" | "remote-asset" | "missing-asset" | "page-output" | "page-script" | "no-astryx-styles" | "chart-size" | "not-embedded" | "node-crash";
58
59
  /**
59
60
  * - What happened and what to do, in one sentence.
60
61
  */
@@ -98,3 +99,7 @@ export type RenderedPage = {
98
99
  dir: string;
99
100
  }[];
100
101
  };
102
+ export type ServedSet = {
103
+ dir: string;
104
+ integrations: readonly string[];
105
+ };
@@ -18,7 +18,8 @@
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';
@@ -34,7 +35,7 @@ export {PAGE_EXTENSIONS} from './embedded-files.mjs';
34
35
 
35
36
  /**
36
37
  * @typedef {object} RenderWarning
37
- * @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.
38
39
  * @property {string} message - What happened and what to do, in one sentence.
39
40
  */
40
41
 
@@ -221,65 +222,158 @@ function expandImports(file, css, ctx) {
221
222
 
222
223
  /** @typedef {{dir: string, integrations: readonly string[]}} ServedSet */
223
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
+
228
+ /**
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
+
224
241
  /**
225
- * Run the page in a worker, stopping it after `timeoutMs`.
226
- * @param {string} entry
227
- * @param {string} root
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
228
281
  * @param {number} timeoutMs
229
- * @param {ServedSet | null} served
230
- * @returns {Promise<any>}
282
+ * @returns {Promise<any>} the page's answer, or `{crashed: how}` when a
283
+ * native crash ended the host
231
284
  */
232
- function runWorker(entry, root, timeoutMs, served) {
285
+ function runWorker(input, timeoutMs) {
233
286
  return new Promise((resolve, reject) => {
234
- const worker = new Worker(new URL('./page-worker.mjs', import.meta.url), {
235
- 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).
236
294
  execArgv: [],
237
- stdout: true,
238
- stderr: true,
239
- resourceLimits: {maxOldGenerationSizeMb: 4096},
295
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
296
+ serialization: 'advanced',
240
297
  });
241
- // The page's own writes to stdout/stderr never reach the CLI's output.
242
- worker.stdout.resume();
243
- worker.stderr.resume();
244
298
  let settled = false;
245
- const timer = setTimeout(() => {
299
+ /** @param {() => void} settle */
300
+ const finish = settle => {
246
301
  if (settled) return;
247
302
  settled = true;
248
- void worker.terminate();
249
- reject(
250
- new RenderError(
251
- 'ERR_RENDER_TIMEOUT',
252
- `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.`,
253
- {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
+ ),
254
315
  ),
255
316
  );
256
317
  }, timeoutMs);
257
- worker.once('message', message => {
258
- if (settled) return;
259
- settled = true;
260
- clearTimeout(timer);
261
- void worker.terminate();
262
- 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
+ });
263
333
  });
264
- worker.once('error', (/** @type {any} */ error) => {
265
- if (settled) return;
266
- settled = true;
267
- clearTimeout(timer);
268
- const out =
269
- /ERR_WORKER_OUT_OF_MEMORY|heap out of memory/.test(String(error?.message ?? error))
270
- ? new RenderError('ERR_RENDER_FAILED', 'The page ran out of memory while rendering.')
271
- : new RenderError('ERR_RENDER_FAILED', `The page could not be rendered: ${String(error?.message ?? error).split('\n')[0]}`);
272
- 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]}`)));
273
336
  });
274
- worker.once('exit', code => {
275
- if (settled) return;
276
- settled = true;
277
- clearTimeout(timer);
278
- 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
+ });
279
344
  });
280
345
  });
281
346
  }
282
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
+
283
377
  /**
284
378
  * Inline local images (src, srcset, poster and CSS url()) as data: URIs. A
285
379
  * link (`href`) loads nothing, so it is left alone.
@@ -371,9 +465,9 @@ export async function renderPage(entry, options = {}) {
371
465
  const dir = path.dirname(entry);
372
466
  const root = projectRootOf(dir);
373
467
  const served = options.served ?? null;
374
- const message = await runWorker(entry, root, options.timeoutMs ?? RENDER_TIMEOUT_MS, served);
375
468
  /** @type {RenderWarning[]} */
376
469
  const warnings = [];
470
+ const message = await runPage({entry, root, served}, options.timeoutMs ?? RENDER_TIMEOUT_MS, warnings);
377
471
  if (!message.ok) {
378
472
  throw new RenderError(message.error.code, message.error.message, message.error.detail);
379
473
  }
@@ -332,7 +332,7 @@ 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',
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  /**
19
- * Curated alias table (from the XLE research, paste P2376666892 §2).
19
+ * Curated alias table (from the XLE alias research, section 2).
20
20
  * Single letters reserved for the highest-frequency structural set.
21
21
  * Collision policy: case-only pairs forbidden; HTML mnemonics win the
22
22
  * Table family; form-frequency wins contested pairs (CB, NI).