@astryxdesign/cli 0.7.12-canary.d84658f → 0.7.12-canary.dfd75a6
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/api/icon/_authoring.mjs +8 -1
- package/api/icon/add/add.mjs +45 -5
- package/api/icon/icon.type.d.mts +24 -0
- package/api/icon/icon.type.mjs +6 -0
- package/api/icon/iconAdd.doc.mjs +1 -1
- package/api/icon/map/map.mjs +11 -4
- package/api/integration/add-helpers.d.mts +5 -1
- package/api/integration/add-helpers.mjs +1 -1
- package/api/render/render.doc.mjs +1 -1
- package/api/render/render.type.d.mts +1 -1
- package/api/render/render.type.mjs +1 -1
- package/assets/discover/official-packages.json +4 -0
- package/assets/docs/tree/icon-sets.doc.mjs +4 -4
- package/assets/docs/tree/render-pages.doc.mjs +1 -1
- package/assets/templates/themes/gothic/gothicTheme.ts +16 -0
- package/clients/cli/commands/experimental/icon.mjs +25 -4
- package/clients/cli/commands/experimental-render.doc.mjs +1 -1
- package/foundation/discovery/icon-set-writer.d.mts +21 -5
- package/foundation/discovery/icon-set-writer.mjs +114 -13
- package/foundation/render/page-host.d.mts +4 -0
- package/foundation/render/page-host.mjs +45 -0
- package/foundation/render/page-worker.mjs +11 -5
- package/foundation/render/render-page.d.mts +11 -6
- package/foundation/render/render-page.mjs +138 -44
- package/foundation/response/error-codes.doc.mjs +1 -1
- 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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1526
|
+
have = statementTrees(actual);
|
|
1438
1527
|
} catch {
|
|
1439
1528
|
return false;
|
|
1440
1529
|
}
|
|
1441
|
-
|
|
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
|
}
|
|
@@ -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
|
|
9
|
-
*
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
226
|
-
*
|
|
227
|
-
|
|
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
|
-
* @
|
|
230
|
-
*
|
|
282
|
+
* @returns {Promise<any>} the page's answer, or `{crashed: how}` when a
|
|
283
|
+
* native crash ended the host
|
|
231
284
|
*/
|
|
232
|
-
function runWorker(
|
|
285
|
+
function runWorker(input, timeoutMs) {
|
|
233
286
|
return new Promise((resolve, reject) => {
|
|
234
|
-
const
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
299
|
+
/** @param {() => void} settle */
|
|
300
|
+
const finish = settle => {
|
|
246
301
|
if (settled) return;
|
|
247
302
|
settled = true;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.7.12-canary.
|
|
3
|
+
"version": "0.7.12-canary.dfd75a6",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"zod": "^4.4.3"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
128
|
+
"@astryxdesign/core": "0.7.12-canary.dfd75a6"
|
|
129
129
|
},
|
|
130
130
|
"peerDependenciesMeta": {
|
|
131
131
|
"@astryxdesign/core": {
|
|
@@ -133,10 +133,10 @@
|
|
|
133
133
|
}
|
|
134
134
|
},
|
|
135
135
|
"devDependencies": {
|
|
136
|
-
"@astryxdesign/charts": "0.7.12-canary.
|
|
137
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
138
|
-
"@astryxdesign/lab": "0.7.12-canary.
|
|
139
|
-
"@astryxdesign/theme-neutral": "0.7.12-canary.
|
|
136
|
+
"@astryxdesign/charts": "0.7.12-canary.dfd75a6",
|
|
137
|
+
"@astryxdesign/core": "0.7.12-canary.dfd75a6",
|
|
138
|
+
"@astryxdesign/lab": "0.7.12-canary.dfd75a6",
|
|
139
|
+
"@astryxdesign/theme-neutral": "0.7.12-canary.dfd75a6",
|
|
140
140
|
"@heroicons/react": "^2.2.0",
|
|
141
141
|
"@stylexjs/stylex": "^0.19.0",
|
|
142
142
|
"@types/babel__core": "^7.20.5",
|