@astryxdesign/cli 0.7.2-canary.d65096c → 0.7.2-canary.e2909cf
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/component/component.test.mjs +80 -0
- package/api/component/list/list.mjs +42 -4
- package/api/integration/pack-check.mjs +10 -10
- package/api/search/search.mjs +8 -3
- package/api/search/search.test.mjs +36 -0
- package/api/template/template.test.mjs +120 -3
- package/api/theme/build/build.family.test.mjs +16 -0
- package/api/theme/build/build.mjs +77 -18
- package/api/theme/themeBuild.doc.mjs +3 -3
- package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
- package/assets/codemods/transforms/next/index.mjs +11 -1
- package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
- package/assets/docs/working-with-ai.doc.mjs +43 -4
- package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
- package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
- package/clients/cli/commands/theme-build.doc.mjs +2 -2
- package/foundation/discovery/component-discovery.d.mts +12 -0
- package/foundation/discovery/component-discovery.mjs +211 -0
- package/foundation/discovery/component-discovery.test.mjs +96 -0
- package/foundation/discovery/template-adapter.mjs +74 -5
- package/foundation/doc-compiler/doc-loads.test.mjs +5 -1
- package/foundation/doc-compiler/inputs.mjs +23 -0
- package/foundation/doc-compiler/inputs.test.mjs +0 -1
- package/foundation/xle/registry.mjs +4 -0
- package/foundation/xle/registry.test.mjs +28 -0
- package/package.json +9 -9
|
@@ -13,6 +13,7 @@ import {describe, it, expect} from 'vitest';
|
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import {fileURLToPath} from 'node:url';
|
|
15
15
|
import {component} from './component.mjs';
|
|
16
|
+
import {search} from '../search/search.mjs';
|
|
16
17
|
import {AstryxError} from '../error.mjs';
|
|
17
18
|
import {runCli} from '../../test-utils/run-cli.mjs';
|
|
18
19
|
|
|
@@ -92,6 +93,85 @@ describe('component dispatcher — detail levels on list', () => {
|
|
|
92
93
|
);
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
describe('component list — components whose docs live in a family folder', () => {
|
|
97
|
+
it('lists HStack, VStack and Heading at every detail level, each with its own import', async () => {
|
|
98
|
+
const {docs: stackDoc} = await import(
|
|
99
|
+
path.join(REPO, 'packages', 'core', 'src', 'Stack', 'Stack.doc.mjs')
|
|
100
|
+
);
|
|
101
|
+
const vstackMember = stackDoc.components.find(c => c.name === 'VStack');
|
|
102
|
+
|
|
103
|
+
const names = await component(undefined, {cwd, list: true});
|
|
104
|
+
const listed = Object.values(names.data.components).flat();
|
|
105
|
+
for (const name of ['HStack', 'VStack', 'Heading']) {
|
|
106
|
+
expect(listed).toContainEqual({name, package: '@astryxdesign/core'});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const compact = await component(undefined, {cwd, list: true, detail: 'compact'});
|
|
110
|
+
const brief = Object.values(compact.data.components).flat();
|
|
111
|
+
expect(brief.find(e => e.name === 'VStack')).toEqual({
|
|
112
|
+
name: 'VStack',
|
|
113
|
+
package: '@astryxdesign/core',
|
|
114
|
+
description: vstackMember.description,
|
|
115
|
+
import: '@astryxdesign/core/VStack',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const full = await component(undefined, {cwd, list: true, detail: 'full'});
|
|
119
|
+
const entry = Object.values(full.data.components)
|
|
120
|
+
.flat()
|
|
121
|
+
.find(e => e.name === 'VStack');
|
|
122
|
+
// Shaped like every other full entry (a component doc plus its package):
|
|
123
|
+
// the member's own name, display name and summary, and no scoping field
|
|
124
|
+
// the full-entry type does not describe.
|
|
125
|
+
expect(entry).toEqual({
|
|
126
|
+
name: 'VStack',
|
|
127
|
+
package: '@astryxdesign/core',
|
|
128
|
+
displayName: vstackMember.displayName,
|
|
129
|
+
group: stackDoc.group,
|
|
130
|
+
category: stackDoc.category,
|
|
131
|
+
usage: {description: vstackMember.description},
|
|
132
|
+
props: vstackMember.props,
|
|
133
|
+
theming: stackDoc.theming,
|
|
134
|
+
import: '@astryxdesign/core/VStack',
|
|
135
|
+
});
|
|
136
|
+
}, SLOW);
|
|
137
|
+
|
|
138
|
+
it('prints each one\'s own import path in the list, the detail, and search', async () => {
|
|
139
|
+
// The path `component <Name>` prints is the one docs and examples teach:
|
|
140
|
+
// the component's own entry point, never its family's (/Stack, /Text) or
|
|
141
|
+
// a re-export (/Layout).
|
|
142
|
+
const compact = Object.values(
|
|
143
|
+
(await component(undefined, {cwd, list: true, detail: 'compact'})).data.components,
|
|
144
|
+
).flat();
|
|
145
|
+
const full = Object.values(
|
|
146
|
+
(await component(undefined, {cwd, list: true, detail: 'full'})).data.components,
|
|
147
|
+
).flat();
|
|
148
|
+
const textList = (await runCli(['component', '--list'], cwd)).stdout;
|
|
149
|
+
const textCompact = (
|
|
150
|
+
await runCli(['component', '--list', '--detail', 'compact'], cwd)
|
|
151
|
+
).stdout;
|
|
152
|
+
/** The `import:` line of a text record, found by its `name:` line. */
|
|
153
|
+
const textImport = (text, name) =>
|
|
154
|
+
new RegExp(`^name:\\s+${name}\\n(?:(?!name:).*\\n)*?import:\\s+(\\S+)`, 'm').exec(
|
|
155
|
+
text,
|
|
156
|
+
)?.[1];
|
|
157
|
+
for (const name of ['VStack', 'HStack', 'Heading']) {
|
|
158
|
+
const expected = `@astryxdesign/core/${name}`;
|
|
159
|
+
expect(compact.find(e => e.name === name)?.import).toBe(expected);
|
|
160
|
+
expect((await component(name, {cwd})).data.import).toBe(expected);
|
|
161
|
+
const hits = (await search(name, {cwd, type: 'component'})).data.results;
|
|
162
|
+
expect(hits.find(r => r.name === name)?.import).toBe(expected);
|
|
163
|
+
expect(textImport(textList, name)).toBe(expected);
|
|
164
|
+
expect(textImport(textCompact, name)).toBe(expected);
|
|
165
|
+
}
|
|
166
|
+
// The full level spreads a component's own doc, which states no import
|
|
167
|
+
// (Button's entry has none either); a member's entry is scoped to the
|
|
168
|
+
// member and states its own.
|
|
169
|
+
for (const name of ['VStack', 'HStack']) {
|
|
170
|
+
expect(full.find(e => e.name === name)?.import).toBe(`@astryxdesign/core/${name}`);
|
|
171
|
+
}
|
|
172
|
+
}, SLOW);
|
|
173
|
+
});
|
|
174
|
+
|
|
95
175
|
describe('component dispatcher — projection precedence (locked)', () => {
|
|
96
176
|
it(
|
|
97
177
|
'--props routes to component.detail.props',
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
discoverComponents,
|
|
17
17
|
discoverExternalComponentsGrouped,
|
|
18
18
|
discoverValidIntegrationComponents,
|
|
19
|
+
familyMember,
|
|
19
20
|
findComponentReadme,
|
|
20
21
|
findExternalComponentDoc,
|
|
21
22
|
resolveImportPath,
|
|
@@ -66,6 +67,33 @@ async function legacyImport(ext, name, coreDir, docOpts) {
|
|
|
66
67
|
*/
|
|
67
68
|
const coreEntry = doc => ({name: doc.name, package: CORE_PACKAGE, ...doc});
|
|
68
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The full-level entry for a component documented as a member of its family's
|
|
72
|
+
* doc (VStack in Stack's doc): a component doc built from the member's own
|
|
73
|
+
* entry, under its own name, display name and import, shaped like every other
|
|
74
|
+
* full entry. Null when the doc is the component's own.
|
|
75
|
+
* @param {any} docs
|
|
76
|
+
* @param {string} name
|
|
77
|
+
* @param {string} coreDir
|
|
78
|
+
* @returns {any}
|
|
79
|
+
*/
|
|
80
|
+
function memberEntry(docs, name, coreDir) {
|
|
81
|
+
const member = familyMember(docs, name);
|
|
82
|
+
if (!member) return null;
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
package: CORE_PACKAGE,
|
|
86
|
+
displayName: member.displayName ?? name,
|
|
87
|
+
...(docs.group ? {group: docs.group} : {}),
|
|
88
|
+
...(docs.category ? {category: docs.category} : {}),
|
|
89
|
+
usage: member.usage ?? {description: member.description ?? ''},
|
|
90
|
+
props: member.props ?? [],
|
|
91
|
+
...(member.examples ? {examples: member.examples} : {}),
|
|
92
|
+
...(docs.theming ? {theming: docs.theming} : {}),
|
|
93
|
+
import: resolveImportPath(coreDir, name),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
/**
|
|
70
98
|
* The entry for a Core slot an integration component replaces
|
|
71
99
|
* (spec:AST-035 FR11), at the list's detail level: the replacement's own name,
|
|
@@ -177,7 +205,11 @@ export async function componentList(
|
|
|
177
205
|
entries.push({
|
|
178
206
|
name: comp,
|
|
179
207
|
package: CORE_PACKAGE,
|
|
180
|
-
description:
|
|
208
|
+
description:
|
|
209
|
+
familyMember(docs, comp)?.description ||
|
|
210
|
+
docs.usage?.description ||
|
|
211
|
+
docs.description ||
|
|
212
|
+
'',
|
|
181
213
|
import: resolveImportPath(coreDir, comp),
|
|
182
214
|
});
|
|
183
215
|
} catch {
|
|
@@ -215,7 +247,8 @@ export async function componentList(
|
|
|
215
247
|
const readme = findComponentReadme(coreDir, comp);
|
|
216
248
|
if (readme && readme.endsWith('.doc.mjs')) {
|
|
217
249
|
try {
|
|
218
|
-
|
|
250
|
+
const docs = await loadComponentDoc(readme, {zh, lang, dense});
|
|
251
|
+
entries.push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
|
|
219
252
|
} catch {
|
|
220
253
|
entries.push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
|
|
221
254
|
}
|
|
@@ -269,7 +302,11 @@ export async function componentList(
|
|
|
269
302
|
result[cat].push({
|
|
270
303
|
name: comp,
|
|
271
304
|
package: CORE_PACKAGE,
|
|
272
|
-
description:
|
|
305
|
+
description:
|
|
306
|
+
familyMember(docs, comp)?.description ||
|
|
307
|
+
docs.usage?.description ||
|
|
308
|
+
docs.description ||
|
|
309
|
+
'',
|
|
273
310
|
import: resolveImportPath(coreDir, comp),
|
|
274
311
|
});
|
|
275
312
|
} catch {
|
|
@@ -310,7 +347,8 @@ export async function componentList(
|
|
|
310
347
|
const readme = findComponentReadme(coreDir, comp);
|
|
311
348
|
if (readme && readme.endsWith('.doc.mjs')) {
|
|
312
349
|
try {
|
|
313
|
-
|
|
350
|
+
const docs = await loadComponentDoc(readme, {zh, lang, dense});
|
|
351
|
+
result[cat].push(memberEntry(docs, comp, coreDir) ?? coreEntry(docs));
|
|
314
352
|
} catch {
|
|
315
353
|
result[cat].push({name: `XDS${comp}`, package: CORE_PACKAGE, description: ''});
|
|
316
354
|
}
|
|
@@ -660,11 +660,10 @@ async function validateIncompleteThemeExports(
|
|
|
660
660
|
const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
|
|
661
661
|
try {
|
|
662
662
|
if (outputs.some(output => output.kind !== 'font stylesheet')) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
);
|
|
663
|
+
// The scratch directory is the write root; the package stays the cwd,
|
|
664
|
+
// so the build selects the package's Core and its paths as before.
|
|
665
|
+
const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
|
|
666
|
+
const receipt = await themeBuild(source, scratchBuild, {cwd: packageDir});
|
|
668
667
|
if (receipt == null || receipt.type !== 'theme.build') {
|
|
669
668
|
throw new Error('the theme build produced no complete receipt');
|
|
670
669
|
}
|
|
@@ -847,11 +846,12 @@ async function validatePackedThemeExports(
|
|
|
847
846
|
);
|
|
848
847
|
const expectedCss = path.join(expectedDir, `${localTheme.slug}.css`);
|
|
849
848
|
try {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
849
|
+
// The scratch directory is the write root; the package stays the cwd,
|
|
850
|
+
// so the build selects the package's Core and its paths as before.
|
|
851
|
+
const scratchBuild = {out: expectedCss, __writeRoot: expectedDir};
|
|
852
|
+
const receipt = await themeBuild(source, scratchBuild, {
|
|
853
|
+
cwd: localIntegration.__packageDir,
|
|
854
|
+
});
|
|
855
855
|
if (receipt == null || receipt.type !== 'theme.build') {
|
|
856
856
|
issues.push(
|
|
857
857
|
error(
|
package/api/search/search.mjs
CHANGED
|
@@ -85,6 +85,7 @@ import {findCoreDir} from '../../foundation/fs/paths.mjs';
|
|
|
85
85
|
import {
|
|
86
86
|
discoverComponents,
|
|
87
87
|
discoverValidIntegrationComponents,
|
|
88
|
+
familyMember,
|
|
88
89
|
findComponentReadme,
|
|
89
90
|
resolveImportPath,
|
|
90
91
|
resolveIntegrationImportPath,
|
|
@@ -1079,7 +1080,11 @@ async function gatherCoreComponents(coreDir) {
|
|
|
1079
1080
|
/** @type {string[]} */
|
|
1080
1081
|
let guidance = [];
|
|
1081
1082
|
if (readme && readme.endsWith('.doc.mjs')) {
|
|
1082
|
-
const
|
|
1083
|
+
const loaded = await loadModuleDoc(readme);
|
|
1084
|
+
// A member documented in its family's doc (VStack in Stack's doc) is
|
|
1085
|
+
// indexed from its own entry: the family's keywords and guidance
|
|
1086
|
+
// describe the family, not the member.
|
|
1087
|
+
const doc = familyMember(loaded, comp) ?? loaded;
|
|
1083
1088
|
if (doc) {
|
|
1084
1089
|
keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
|
|
1085
1090
|
description = doc.usage?.description || doc.description || '';
|
|
@@ -1225,11 +1230,11 @@ export async function componentKeywords(coreDir, cwd) {
|
|
|
1225
1230
|
.flat()
|
|
1226
1231
|
.map(async name => {
|
|
1227
1232
|
const readme = findComponentReadme(coreDir, name);
|
|
1228
|
-
const
|
|
1233
|
+
const loaded =
|
|
1229
1234
|
readme && readme.endsWith('.doc.mjs')
|
|
1230
1235
|
? await loadModuleDoc(readme)
|
|
1231
1236
|
: null;
|
|
1232
|
-
return {name, keywords: keywordsOf(
|
|
1237
|
+
return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
|
|
1233
1238
|
});
|
|
1234
1239
|
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1235
1240
|
const replacements = await loadComponentReplacements(
|
|
@@ -616,6 +616,42 @@ describe('search leaf — a theme is found by its names; its description is pros
|
|
|
616
616
|
);
|
|
617
617
|
});
|
|
618
618
|
|
|
619
|
+
describe('search leaf — components whose docs live in a family folder', () => {
|
|
620
|
+
it("indexes a family member from its own entry, not the family's keywords", async () => {
|
|
621
|
+
// HStack and VStack are documented in Stack's doc, whose keywords
|
|
622
|
+
// (horizontal, vertical, row, column, ...) describe Stack.
|
|
623
|
+
const reasons = async (/** @type {string} */ query) =>
|
|
624
|
+
Object.fromEntries(
|
|
625
|
+
(await search(query, {cwd, type: 'component'})).data.results.map(r => [
|
|
626
|
+
r.name,
|
|
627
|
+
r.reason,
|
|
628
|
+
]),
|
|
629
|
+
);
|
|
630
|
+
const column = await reasons('column');
|
|
631
|
+
expect(column.HStack ?? '').not.toMatch(/keyword/);
|
|
632
|
+
expect(column.VStack ?? '').not.toMatch(/keyword/);
|
|
633
|
+
expect(column.Stack).toMatch(/keyword/);
|
|
634
|
+
const vertical = (await search('vertical', {cwd, type: 'component'})).data.results.map(
|
|
635
|
+
r => r.name,
|
|
636
|
+
);
|
|
637
|
+
expect(vertical.indexOf('VStack')).toBeGreaterThanOrEqual(0);
|
|
638
|
+
if (vertical.includes('HStack')) {
|
|
639
|
+
expect(vertical.indexOf('VStack')).toBeLessThan(vertical.indexOf('HStack'));
|
|
640
|
+
}
|
|
641
|
+
}, SLOW);
|
|
642
|
+
|
|
643
|
+
it('ranks VStack, HStack and Heading first for their own names, with their own import', async () => {
|
|
644
|
+
for (const name of ['VStack', 'HStack', 'Heading']) {
|
|
645
|
+
const r = await search(name.toLowerCase(), {cwd, type: 'component'});
|
|
646
|
+
expect(r.data.results[0]).toMatchObject({
|
|
647
|
+
domain: 'component',
|
|
648
|
+
name,
|
|
649
|
+
import: `@astryxdesign/core/${name}`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}, SLOW);
|
|
653
|
+
});
|
|
654
|
+
|
|
619
655
|
describe('search leaf — integration components', () => {
|
|
620
656
|
/**
|
|
621
657
|
* A minimal consumer project: a stub `@astryxdesign/core` (so `findCoreDir`
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
3
6
|
import {describe, it, expect} from 'vitest';
|
|
4
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
stripTemplateAssetRefs,
|
|
9
|
+
template,
|
|
10
|
+
extractComponents,
|
|
11
|
+
} from './template.mjs';
|
|
5
12
|
|
|
6
13
|
describe('stripTemplateAssetRefs', () => {
|
|
7
14
|
it('replaces a /template-assets image path with an inline data URI', () => {
|
|
@@ -31,8 +38,7 @@ describe('stripTemplateAssetRefs', () => {
|
|
|
31
38
|
});
|
|
32
39
|
|
|
33
40
|
it('preserves surrounding source structure', () => {
|
|
34
|
-
const src =
|
|
35
|
-
"const data = [{src: '/template-assets/x.png', alt: 'X'}];";
|
|
41
|
+
const src = "const data = [{src: '/template-assets/x.png', alt: 'X'}];";
|
|
36
42
|
const out = stripTemplateAssetRefs(src);
|
|
37
43
|
expect(out).toContain("alt: 'X'");
|
|
38
44
|
expect(out).toContain('const data = [{src:');
|
|
@@ -102,4 +108,115 @@ describe('template --skeleton component extraction (prefix-agnostic)', () => {
|
|
|
102
108
|
|
|
103
109
|
expect(result.data.skeleton).toContain('columns={{minWidth: 200}}');
|
|
104
110
|
});
|
|
111
|
+
|
|
112
|
+
it('filters out local helper components declared in the same file', () => {
|
|
113
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
114
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
pagePath,
|
|
117
|
+
`
|
|
118
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
119
|
+
|
|
120
|
+
function TimelineSection() {
|
|
121
|
+
return <div />;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default function Page() {
|
|
125
|
+
return (
|
|
126
|
+
<Card>
|
|
127
|
+
<TimelineSection />
|
|
128
|
+
</Card>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
`,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const components = extractComponents(pagePath);
|
|
136
|
+
expect(components).toContain('Card');
|
|
137
|
+
expect(components).not.toContain('Timeline');
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('filters out a type-annotated local helper', () => {
|
|
144
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
145
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
146
|
+
fs.writeFileSync(
|
|
147
|
+
pagePath,
|
|
148
|
+
`
|
|
149
|
+
import type {FC} from 'react';
|
|
150
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
151
|
+
|
|
152
|
+
const TimelineSection: FC = () => <div />;
|
|
153
|
+
|
|
154
|
+
export default function Page() {
|
|
155
|
+
return (
|
|
156
|
+
<Card>
|
|
157
|
+
<TimelineSection />
|
|
158
|
+
</Card>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
try {
|
|
164
|
+
const components = extractComponents(pagePath);
|
|
165
|
+
expect(components).toContain('Card');
|
|
166
|
+
expect(components).not.toContain('Timeline');
|
|
167
|
+
} finally {
|
|
168
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('keeps an imported component when declaration-like text appears in a comment or string', () => {
|
|
173
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
174
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
175
|
+
fs.writeFileSync(
|
|
176
|
+
pagePath,
|
|
177
|
+
`
|
|
178
|
+
import {Card} from '@astryxdesign/core/Layout';
|
|
179
|
+
|
|
180
|
+
// const Card = () => null;
|
|
181
|
+
const note = 'function Card() {}';
|
|
182
|
+
|
|
183
|
+
export default function Page() {
|
|
184
|
+
return <Card>{note}</Card>;
|
|
185
|
+
}
|
|
186
|
+
`,
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
expect(extractComponents(pagePath)).toContain('Card');
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('keeps a bare imported component whose name is itself a stripped suffix', () => {
|
|
196
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-test-'));
|
|
197
|
+
const pagePath = path.join(tmpDir, 'page.tsx');
|
|
198
|
+
fs.writeFileSync(
|
|
199
|
+
pagePath,
|
|
200
|
+
`
|
|
201
|
+
import {Section} from '@astryxdesign/core/Section';
|
|
202
|
+
|
|
203
|
+
function SectionHeading() {
|
|
204
|
+
return <div />;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default function Page() {
|
|
208
|
+
return (
|
|
209
|
+
<Section>
|
|
210
|
+
<SectionHeading />
|
|
211
|
+
</Section>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
`,
|
|
215
|
+
);
|
|
216
|
+
try {
|
|
217
|
+
expect(extractComponents(pagePath)).toContain('Section');
|
|
218
|
+
} finally {
|
|
219
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
105
222
|
});
|
|
@@ -252,6 +252,22 @@ describe('themeBuildFamily()', () => {
|
|
|
252
252
|
expect(fs.existsSync(dtsPath)).toBe(false);
|
|
253
253
|
});
|
|
254
254
|
|
|
255
|
+
it('refuses family outputs outside cwd before checking or writing', async () => {
|
|
256
|
+
const dir = makeDir();
|
|
257
|
+
const files = writeFamily(dir).map(file => path.join(dir, file));
|
|
258
|
+
const app = path.join(dir, 'app');
|
|
259
|
+
fs.mkdirSync(app);
|
|
260
|
+
|
|
261
|
+
for (const check of [false, true]) {
|
|
262
|
+
await expect(
|
|
263
|
+
themeBuildFamily(files, {familyKey: FAMILY_KEY, check}, {cwd: app}),
|
|
264
|
+
).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
|
|
265
|
+
}
|
|
266
|
+
for (const output of OUTPUTS) {
|
|
267
|
+
expect(fs.existsSync(path.join(dir, 'themes', output))).toBe(false);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
255
271
|
it('fails without publishing a mixed trio when staging a write fails', async () => {
|
|
256
272
|
const dir = makeDir();
|
|
257
273
|
const files = writeFamily(dir);
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* files; an internal callback lets the CLI make only the process status fail.
|
|
28
28
|
* Human progress is emitted through the shared `logger` (silent by default), so
|
|
29
29
|
* the CLI keeps its exact output while a programmatic caller stays quiet.
|
|
30
|
+
* Every output, explicit or beside the theme file, stays inside `cwd`
|
|
31
|
+
* (`ERR_PATH_TRAVERSAL` otherwise), in check mode too.
|
|
30
32
|
*
|
|
31
33
|
* The installed `@astryxdesign/core` is an independently versioned optional
|
|
32
34
|
* peer, so it can be older than the CLI. A theme that uses only baseline
|
|
@@ -270,6 +272,25 @@ function realOrSelf(file) {
|
|
|
270
272
|
}
|
|
271
273
|
}
|
|
272
274
|
|
|
275
|
+
/**
|
|
276
|
+
* `target` spelled through real directories: the deepest ancestor that exists
|
|
277
|
+
* is resolved through its symlinks, and the missing rest is appended as given.
|
|
278
|
+
* @param {string} target absolute path
|
|
279
|
+
* @returns {string}
|
|
280
|
+
*/
|
|
281
|
+
function realLocation(target) {
|
|
282
|
+
/** @type {string[]} */
|
|
283
|
+
const missing = [];
|
|
284
|
+
let existing = target;
|
|
285
|
+
while (!fs.existsSync(existing)) {
|
|
286
|
+
const parent = path.dirname(existing);
|
|
287
|
+
if (parent === existing) break;
|
|
288
|
+
missing.unshift(path.basename(existing));
|
|
289
|
+
existing = parent;
|
|
290
|
+
}
|
|
291
|
+
return path.join(realOrSelf(existing), ...missing);
|
|
292
|
+
}
|
|
293
|
+
|
|
273
294
|
/**
|
|
274
295
|
* The Core the project at `cwd` installed, when it differs from the one the
|
|
275
296
|
* CLI's own import found and loads. The app's `<Theme>` runs on the app's
|
|
@@ -506,6 +527,47 @@ function checkedBuildOutputs(writes, cwd) {
|
|
|
506
527
|
.map(write => path.relative(cwd, write.dest));
|
|
507
528
|
}
|
|
508
529
|
|
|
530
|
+
/** Remedies that end a confinement error, by where the output path came from. */
|
|
531
|
+
const OUT_REMEDY = 'Pass an --out path inside it.';
|
|
532
|
+
const SOURCE_REMEDY =
|
|
533
|
+
'Without --out the outputs go beside the theme file, so run the build from a directory that contains the theme file, or pass an --out path inside the working directory.';
|
|
534
|
+
const FAMILY_REMEDY =
|
|
535
|
+
"Family outputs go beside the family's root theme file, so run the build from a directory that contains it.";
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Refuse an output outside `root`: every write is confined, whether `out` names
|
|
539
|
+
* the path or it defaults to the theme file's directory (architecture:cli-surface
|
|
540
|
+
* INV10). Both sides are compared by real location, so an absolute path that
|
|
541
|
+
* reaches the working directory through a symlink is inside it, and a symlink
|
|
542
|
+
* inside it that leads elsewhere is not.
|
|
543
|
+
* @param {string} dest absolute output path
|
|
544
|
+
* @param {string} root the directory writes stay inside: the working directory
|
|
545
|
+
* @param {string} remedy what to do instead; ends the error message
|
|
546
|
+
* @param {string} [shown] the path as the caller wrote it, for the message
|
|
547
|
+
*/
|
|
548
|
+
function confineOutput(dest, root, remedy, shown = dest) {
|
|
549
|
+
try {
|
|
550
|
+
assertWithin(realLocation(dest), realOrSelf(path.resolve(root)), {
|
|
551
|
+
allowAbsolute: true,
|
|
552
|
+
label: 'output path',
|
|
553
|
+
});
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (!(err instanceof PathSafetyError)) throw err;
|
|
556
|
+
throw new AstryxError(
|
|
557
|
+
err.code === ERROR_CODES.ERR_PATH_TRAVERSAL
|
|
558
|
+
? `Output path "${shown}" is outside the working directory (${root}). ${remedy}`
|
|
559
|
+
: err.message,
|
|
560
|
+
undefined,
|
|
561
|
+
ERROR_CODES.ERR_PATH_TRAVERSAL,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** @param {Array<{dest: string}>} writes @param {string} root @param {string} remedy */
|
|
567
|
+
function confineOutputs(writes, root, remedy) {
|
|
568
|
+
for (const write of writes) confineOutput(write.dest, root, remedy);
|
|
569
|
+
}
|
|
570
|
+
|
|
509
571
|
/** @param {Array<{dest: string, content: string}>} writes */
|
|
510
572
|
function writeBuildOutputs(writes) {
|
|
511
573
|
if (writes.length === 0) return;
|
|
@@ -2344,9 +2406,10 @@ function validateHeadingTypeAugmentationSupport(themeDef) {
|
|
|
2344
2406
|
* `logger` (silent by default).
|
|
2345
2407
|
*
|
|
2346
2408
|
* @param {string} file - Theme file path, resolved against `cwd`.
|
|
2347
|
-
* @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void}} [options] -
|
|
2409
|
+
* @param {{out?: string, check?: boolean, iconsSpecifier?: string, __prepareFamily?: boolean, __familyLoader?: any, __familyInterception?: any, __onPrivateVarError?: () => void, __writeRoot?: string}} [options] -
|
|
2348
2410
|
* `out` overrides the output CSS path; `check` compares against on-disk outputs
|
|
2349
|
-
* instead of writing. `
|
|
2411
|
+
* instead of writing. `__writeRoot` replaces `cwd` as the directory every
|
|
2412
|
+
* output must stay inside, for a CLI-internal scratch build. `iconsSpecifier` overrides the icon registry import
|
|
2350
2413
|
* specifier in the generated module (e.g. `./icons.mjs`); when omitted, the
|
|
2351
2414
|
* direct registry specifiers from the theme source are emitted unchanged.
|
|
2352
2415
|
* @param {{cwd?: string}} [ctx]
|
|
@@ -2791,25 +2854,14 @@ async function themeBuildInternal(
|
|
|
2791
2854
|
// Derive the default CSS name from the theme name so .css/.js/.d.ts
|
|
2792
2855
|
// share one scheme; an explicit --out still wins.
|
|
2793
2856
|
const baseName = themeDef.name;
|
|
2857
|
+
// Every write stays inside cwd. A CLI-internal caller that renders into a
|
|
2858
|
+
// scratch directory it creates and removes names that directory instead.
|
|
2859
|
+
const writeRoot = options.__writeRoot ?? cwd;
|
|
2794
2860
|
let outPath;
|
|
2795
2861
|
if (options.out) {
|
|
2862
|
+
// Relative or absolute, the explicit output stays inside the write root.
|
|
2796
2863
|
outPath = path.resolve(cwd, options.out);
|
|
2797
|
-
|
|
2798
|
-
// trusted (the user explicitly controls where output goes, like gcc -o).
|
|
2799
|
-
if (!path.isAbsolute(options.out)) {
|
|
2800
|
-
try {
|
|
2801
|
-
assertWithin(options.out, cwd, {label: 'output path'});
|
|
2802
|
-
} catch (err) {
|
|
2803
|
-
if (err instanceof PathSafetyError) {
|
|
2804
|
-
throw new AstryxError(
|
|
2805
|
-
err.message,
|
|
2806
|
-
undefined,
|
|
2807
|
-
ERROR_CODES.ERR_PATH_TRAVERSAL,
|
|
2808
|
-
);
|
|
2809
|
-
}
|
|
2810
|
-
throw err;
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2864
|
+
confineOutput(outPath, writeRoot, OUT_REMEDY, options.out);
|
|
2813
2865
|
} else {
|
|
2814
2866
|
outPath = path.join(path.dirname(filePath), `${baseName}.css`);
|
|
2815
2867
|
}
|
|
@@ -2905,6 +2957,11 @@ async function themeBuildInternal(
|
|
|
2905
2957
|
writes.push({dest: variantDtsPath, content: variantContent});
|
|
2906
2958
|
}
|
|
2907
2959
|
|
|
2960
|
+
// Every output stays inside cwd, including the defaults beside the theme
|
|
2961
|
+
// file. Check mode refuses the same paths, so it never passes outputs that a
|
|
2962
|
+
// build would refuse to write.
|
|
2963
|
+
confineOutputs(writes, writeRoot, options.out ? OUT_REMEDY : SOURCE_REMEDY);
|
|
2964
|
+
|
|
2908
2965
|
// Check mode: compare generated content against what's on disk instead of
|
|
2909
2966
|
// writing. A file is "stale" if it's missing or its content differs once the
|
|
2910
2967
|
// volatile @generated `Command:` line is ignored. Returns a
|
|
@@ -3449,6 +3506,8 @@ export async function themeBuildFamily(
|
|
|
3449
3506
|
js: path.relative(cwd, jsPath),
|
|
3450
3507
|
dts: path.relative(cwd, dtsPath),
|
|
3451
3508
|
};
|
|
3509
|
+
// The family's outputs stay inside cwd too, in check mode as well.
|
|
3510
|
+
confineOutputs(writes, cwd, FAMILY_REMEDY);
|
|
3452
3511
|
if (options.check) {
|
|
3453
3512
|
const stale = staleBuildOutputs(writes, cwd);
|
|
3454
3513
|
const upToDate = stale.length === 0;
|
|
@@ -58,7 +58,7 @@ export const doc = {
|
|
|
58
58
|
name: 'options.out',
|
|
59
59
|
type: 'string',
|
|
60
60
|
description:
|
|
61
|
-
'Override the output CSS path. The .js, .d.ts and any .variants.d.ts are written in the same directory, named after the theme (<name>.js), not after the CSS file.
|
|
61
|
+
'Override the output CSS path, relative or absolute; it must resolve inside cwd. The .js, .d.ts and any .variants.d.ts are written in the same directory, named after the theme (<name>.js), not after the CSS file.',
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
name: 'options.check',
|
|
@@ -77,7 +77,7 @@ export const doc = {
|
|
|
77
77
|
name: 'ctx.cwd',
|
|
78
78
|
type: 'string',
|
|
79
79
|
description:
|
|
80
|
-
'Directory the theme file, a relative out path, and the returned output paths resolve against.',
|
|
80
|
+
'Directory the theme file, a relative out path, and the returned output paths resolve against. Every output is written inside it, including the default outputs beside the theme file.',
|
|
81
81
|
default: 'process.cwd()',
|
|
82
82
|
},
|
|
83
83
|
],
|
|
@@ -105,7 +105,7 @@ export const doc = {
|
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
code: 'ERR_PATH_TRAVERSAL',
|
|
108
|
-
when: 'the theme name contains a path separator or traversal marker, or a
|
|
108
|
+
when: 'the theme name contains a path separator or traversal marker, or an output resolves outside cwd (including through a symlink): an `out` path, relative or absolute, or the default outputs beside a theme file outside cwd (also rejected in check mode)',
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
code: 'ERR_CORE_NOT_FOUND',
|