@astryxdesign/cli 0.7.2-canary.db3a2e7 → 0.7.2-canary.e26723d

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 (142) hide show
  1. package/README.md +7 -7
  2. package/api/build/help/help.mjs +1 -1
  3. package/api/component/_adapter.d.mts +41 -1
  4. package/api/component/_adapter.mjs +93 -7
  5. package/api/component/component.mjs +22 -5
  6. package/api/component/component.test.mjs +420 -167
  7. package/api/component/integration-export-name.test.mjs +644 -0
  8. package/api/component/list/list.mjs +42 -4
  9. package/api/docs/_adapter.mjs +207 -1
  10. package/api/docs/integrationDocs.test.mjs +124 -0
  11. package/api/doctor/theme-checks.mjs +1 -1
  12. package/api/gap-report/gap-report.mjs +67 -4
  13. package/api/init/run/run.mjs +2 -2
  14. package/api/integration/_adapter.d.mts +12 -0
  15. package/api/integration/_adapter.mjs +54 -0
  16. package/api/integration/add-contribution.component-names.test.mjs +66 -0
  17. package/api/integration/add-contribution.mjs +7 -3
  18. package/api/integration/authoring-checks.mjs +11 -2
  19. package/api/integration/authoring-checks.test.mjs +39 -0
  20. package/api/integration/authoring-checks.type.d.mts +9 -0
  21. package/api/integration/authoring-checks.type.mjs +14 -1
  22. package/api/integration/integrationComponentConflicts.doc.mjs +21 -5
  23. package/api/integration/pack-check.mjs +61 -107
  24. package/api/integration/pack-check.test.mjs +359 -0
  25. package/api/integration/validate-integration.test.mjs +11 -0
  26. package/api/search/search.d.mts +9 -0
  27. package/api/search/search.mjs +108 -16
  28. package/api/search/search.test.mjs +36 -0
  29. package/api/swizzle/copy/copy.mjs +28 -14
  30. package/api/template/template.test.mjs +120 -3
  31. package/api/theme/build/build.family.test.mjs +16 -0
  32. package/api/theme/build/build.mjs +81 -22
  33. package/api/theme/themeBuild.doc.mjs +3 -3
  34. package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs +120 -0
  35. package/assets/codemods/transforms/next/index.mjs +11 -1
  36. package/assets/codemods/transforms/next/migrate-list-edge-compensation-to-edge.mjs +198 -0
  37. package/assets/docs/README.md +10 -0
  38. package/assets/docs/author-a-theme.doc.mjs +1 -1
  39. package/assets/docs/getting-started.doc.mjs +3 -3
  40. package/assets/docs/theme.doc.dense.mjs +1 -1
  41. package/assets/docs/theme.doc.mjs +1 -1
  42. package/assets/docs/theme.doc.zh.mjs +1 -1
  43. package/assets/docs/tree/add-a-component.doc.mjs +10 -0
  44. package/assets/docs/tree/check-the-props.doc.mjs +145 -0
  45. package/assets/docs/tree/checks.doc.mjs +4 -4
  46. package/assets/docs/tree/component-family.doc.mjs +5 -1
  47. package/assets/docs/tree/component-imports.doc.mjs +8 -0
  48. package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
  49. package/assets/docs/tree/links.doc.mjs +2 -2
  50. package/assets/docs/tree/single-component.doc.mjs +3 -2
  51. package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
  52. package/assets/docs/tree/type-scale.doc.mjs +3 -3
  53. package/assets/docs/tree/versioning.doc.mjs +1 -0
  54. package/assets/docs/tree/write-the-template-file.doc.mjs +2 -1
  55. package/assets/docs/use-a-theme.doc.mjs +2 -2
  56. package/assets/docs/working-with-ai.doc.mjs +43 -4
  57. package/assets/templates/blocks/components/Drawer/DrawerForm.doc.mjs +24 -0
  58. package/assets/templates/blocks/components/Drawer/DrawerForm.tsx +62 -0
  59. package/assets/templates/blocks/components/Drawer/DrawerScrim.doc.mjs +22 -0
  60. package/assets/templates/blocks/components/Drawer/DrawerScrim.tsx +68 -0
  61. package/assets/templates/blocks/components/Drawer/DrawerShowcase.doc.mjs +22 -0
  62. package/assets/templates/blocks/components/Drawer/DrawerShowcase.tsx +41 -0
  63. package/assets/templates/blocks/components/Drawer/DrawerStart.doc.mjs +21 -0
  64. package/assets/templates/blocks/components/Drawer/DrawerStart.tsx +41 -0
  65. package/assets/templates/blocks/components/Drawer/DrawerWidths.doc.mjs +22 -0
  66. package/assets/templates/blocks/components/Drawer/DrawerWidths.tsx +54 -0
  67. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.doc.mjs +15 -0
  68. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderShowcase.tsx +73 -0
  69. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.doc.mjs +22 -0
  70. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderSubtitleEndContent.tsx +49 -0
  71. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.doc.mjs +21 -0
  72. package/assets/templates/blocks/components/DrawerHeader/DrawerHeaderWithCloseButton.tsx +42 -0
  73. package/assets/templates/pages/detail-page/page.tsx +3 -3
  74. package/assets/templates/pages/table-inbox/page.tsx +10 -8
  75. package/assets/theme.template.ts +1 -1
  76. package/authoring/doctypes/base/type.ts +6 -0
  77. package/authoring/doctypes/component/component.doc.mjs +4 -4
  78. package/authoring/doctypes/component/type.ts +6 -3
  79. package/authoring/doctypes/template/integration-template.doc.d.mts +11 -0
  80. package/authoring/doctypes/template/integration-template.doc.mjs +49 -0
  81. package/authoring/doctypes/template/parse.d.mts +6 -2
  82. package/authoring/doctypes/template/parse.mjs +3 -1
  83. package/authoring/doctypes/template/type.ts +22 -0
  84. package/authoring/index.d.ts +1 -0
  85. package/authoring/integration/integration.doc.mjs +8 -1
  86. package/authoring/integration/parse.test.mjs +10 -0
  87. package/authoring/integration/schema.d.mts +2 -0
  88. package/authoring/integration/schema.mjs +1 -0
  89. package/authoring/integration/type.ts +4 -0
  90. package/clients/cli/commands/build-theme.batch-report.test.mjs +3 -1
  91. package/clients/cli/commands/build-theme.mjs +1 -1
  92. package/clients/cli/commands/build-theme.path-safety.test.mjs +84 -0
  93. package/clients/cli/commands/build-theme.registry.test.mjs +5 -2
  94. package/clients/cli/commands/doctor-integration-components.doc.mjs +9 -3
  95. package/clients/cli/commands/doctor-integration.test.mjs +392 -0
  96. package/clients/cli/commands/doctor.mjs +74 -0
  97. package/clients/cli/commands/theme-build.doc.mjs +2 -2
  98. package/clients/cli/lib/component-format.mjs +5 -1
  99. package/foundation/config/project.mjs +82 -0
  100. package/foundation/discovery/authoring-self-docs.mjs +1 -0
  101. package/foundation/discovery/component-discovery.d.mts +190 -10
  102. package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
  103. package/foundation/discovery/component-discovery.mjs +864 -56
  104. package/foundation/discovery/component-discovery.test.mjs +96 -0
  105. package/foundation/discovery/component-replacement.mjs +18 -0
  106. package/foundation/discovery/component-replacement.test.mjs +40 -0
  107. package/foundation/discovery/doc-types.d.mts +157 -0
  108. package/foundation/discovery/doc-types.mjs +1224 -0
  109. package/foundation/discovery/doc-types.test.mjs +652 -0
  110. package/foundation/discovery/docs-discovery.d.mts +81 -0
  111. package/foundation/discovery/docs-discovery.mjs +57 -0
  112. package/foundation/discovery/module-exports.d.mts +27 -0
  113. package/foundation/discovery/module-exports.mjs +356 -31
  114. package/foundation/discovery/module-exports.test.mjs +172 -0
  115. package/foundation/discovery/package-tsconfig.d.mts +14 -0
  116. package/foundation/discovery/package-tsconfig.mjs +228 -0
  117. package/foundation/discovery/package-tsconfig.test.mjs +210 -0
  118. package/foundation/discovery/project-typescript.d.mts +23 -0
  119. package/foundation/discovery/project-typescript.mjs +61 -0
  120. package/foundation/discovery/prop-drift.d.mts +220 -0
  121. package/foundation/discovery/prop-drift.mjs +1765 -0
  122. package/foundation/discovery/prop-drift.test.mjs +1197 -0
  123. package/foundation/discovery/template-adapter.mjs +74 -5
  124. package/foundation/discovery/type-match.d.mts +53 -0
  125. package/foundation/discovery/type-match.mjs +660 -0
  126. package/foundation/discovery/type-match.test.mjs +169 -0
  127. package/foundation/discovery/type-text.d.mts +68 -0
  128. package/foundation/discovery/type-text.mjs +658 -0
  129. package/foundation/discovery/type-text.test.mjs +196 -0
  130. package/foundation/discovery/unresolved-references.d.mts +62 -0
  131. package/foundation/discovery/unresolved-references.mjs +790 -0
  132. package/foundation/discovery/unresolved-references.test.mjs +567 -0
  133. package/foundation/doc-compiler/doc-loads.test.mjs +9 -1
  134. package/foundation/doc-compiler/inputs.mjs +23 -0
  135. package/foundation/doc-compiler/inputs.test.mjs +0 -1
  136. package/foundation/doc-compiler/links.d.mts +9 -5
  137. package/foundation/doc-compiler/links.mjs +4 -2
  138. package/foundation/integrations/contribution-inventory.mjs +6 -1
  139. package/foundation/response/response-types.doc.mjs +1 -1
  140. package/foundation/xle/registry.mjs +4 -0
  141. package/foundation/xle/registry.test.mjs +28 -0
  142. package/package.json +10 -10
@@ -21,8 +21,10 @@ import {checkGhCli} from '../_github.mjs';
21
21
  import {Project} from '../../../foundation/config/project.mjs';
22
22
  import {
23
23
  CORE_PACKAGE,
24
- findIntegrationComponentDoc,
25
- findIntegrationComponentSource,
24
+ docStem,
25
+ findComponentReadme,
26
+ findIntegrationComponent,
27
+ unscopedComponentOwners,
26
28
  } from '../../../foundation/discovery/component-discovery.mjs';
27
29
  import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs';
28
30
  import {AstryxError, writeFailed} from '../../error.mjs';
@@ -126,33 +128,38 @@ async function loadConfigSafely(cwd) {
126
128
 
127
129
  /**
128
130
  * Build the set of OWNER packages that provide a component named `name` across
129
- * core + every loaded integration.
131
+ * core + every loaded integration. Core holds every name it documents, but has
132
+ * source to copy (`sourceDir`) only for a name with its own folder.
130
133
  * @param {string} coreDir
131
134
  * @param {Array<{name: string, components?: string, issuesUrl?: string}>} loadedIntegrations
132
135
  * @param {string} name
133
136
  * @param {string|undefined} coreIssuesUrl
134
- * @returns {Array<{package: string, sourceDir: string|null, ownerPackage: string, issuesUrl: string|undefined}>}
137
+ * @returns {Array<{package: string, sourceDir: string|null, docPath: string|null, ownerPackage: string, issuesUrl: string|undefined, hold: string}>}
135
138
  */
136
139
  function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
137
140
  const owners = [];
138
141
  const coreComponentDir = path.join(coreDir, 'src', name);
139
- if (fs.existsSync(coreComponentDir)) {
142
+ const coreFolder = fs.existsSync(coreComponentDir);
143
+ if (coreFolder || findComponentReadme(coreDir, name)) {
140
144
  owners.push({
141
145
  package: CORE_PACKAGE,
142
- sourceDir: coreComponentDir,
146
+ sourceDir: coreFolder ? coreComponentDir : null,
147
+ docPath: null,
143
148
  ownerPackage: CORE_PACKAGE,
144
149
  issuesUrl: coreIssuesUrl || DEFAULT_ISSUES_URL,
150
+ hold: 'core',
145
151
  });
146
152
  }
147
153
  for (const integration of loadedIntegrations) {
148
- const docPath = findIntegrationComponentDoc(integration, name);
149
- if (!docPath) continue;
150
- const sourcePath = findIntegrationComponentSource(integration, name);
154
+ const found = findIntegrationComponent(integration, name);
155
+ if (!found) continue;
151
156
  owners.push({
152
157
  package: integration.name,
153
- sourceDir: sourcePath ? path.dirname(sourcePath) : null,
158
+ sourceDir: found.sourcePath ? path.dirname(found.sourcePath) : null,
159
+ docPath: found.docPath,
154
160
  ownerPackage: integration.name,
155
161
  issuesUrl: integration.issuesUrl,
162
+ hold: found.hold,
156
163
  });
157
164
  }
158
165
  return owners;
@@ -222,25 +229,32 @@ export async function swizzleCopy(component, options = {}) {
222
229
  : undefined;
223
230
  // An active integration replacement answers to the Core name it replaces,
224
231
  // bare or qualified by its own package (spec:AST-035 FR11): copy it under its
225
- // own name. `--package @astryxdesign/core` still copies the Core original.
232
+ // doc's file name, the folder it has always gone to. `--package
233
+ // @astryxdesign/core` still copies the Core original.
226
234
  if (pkg !== CORE_PACKAGE) {
227
235
  const replacement = await resolveComponentReplacement(
228
236
  coreDir,
229
237
  loadedIntegrations,
230
238
  dirName,
231
239
  );
240
+ const copyName = replacement ? docStem(replacement.docPath) : null;
232
241
  if (
233
242
  replacement &&
234
- (!pkg || (pkg === replacement.package && replacement.name !== dirName))
243
+ copyName &&
244
+ (!pkg || (pkg === replacement.package && copyName !== dirName))
235
245
  ) {
236
- return swizzleCopy(replacement.name, {
246
+ return swizzleCopy(copyName, {
237
247
  ...options,
238
248
  package: replacement.package,
239
249
  });
240
250
  }
241
251
  }
242
252
 
243
- const allOwners = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
253
+ const found = resolveOwners(coreDir, loadedIntegrations, dirName, coreIssuesUrl);
254
+ // The owner rule, then a Core name without its own folder drops out.
255
+ const allOwners = (pkg ? found : unscopedComponentOwners(found)).filter(
256
+ owner => owner.package !== CORE_PACKAGE || owner.sourceDir != null,
257
+ );
244
258
 
245
259
  if (allOwners.length === 0) {
246
260
  throw new AstryxError(
@@ -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 {stripTemplateAssetRefs, template} from './template.mjs';
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. `iconsSpecifier` overrides the icon registry import
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
- // Guard: relative paths must not escape cwd via `../`. Absolute paths are
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
@@ -3036,7 +3093,7 @@ function printBuildTrailer(trailer) {
3036
3093
  logger.log(`
3037
3094
  Install in your app (paths are relative to a file in src/; adjust if yours lives elsewhere):
3038
3095
 
3039
- import { Theme } from '@astryxdesign/core';
3096
+ import { Theme } from '@astryxdesign/core/theme';
3040
3097
  import { ${trailer.exportName} } from '${trailer.jsImport}';
3041
3098
  import '${trailer.cssImport}';
3042
3099
 
@@ -3046,7 +3103,7 @@ Install in your app (paths are relative to a file in src/; adjust if yours lives
3046
3103
 
3047
3104
  Or with a <link> tag:
3048
3105
 
3049
- import { Theme } from '@astryxdesign/core';
3106
+ import { Theme } from '@astryxdesign/core/theme';
3050
3107
  import { ${trailer.exportName} } from '${trailer.jsImport}';
3051
3108
 
3052
3109
  <link rel="stylesheet" href="${trailer.cssImport}" />
@@ -3072,7 +3129,7 @@ export function printBatchTrailer(trailers) {
3072
3129
  logger.log(`
3073
3130
  Install in your app (paths are relative to a file in src/; adjust if yours lives elsewhere):
3074
3131
 
3075
- import { Theme } from '@astryxdesign/core';
3132
+ import { Theme } from '@astryxdesign/core/theme';
3076
3133
  import { ${first.exportName} } from '${first.jsImport}';
3077
3134
  import '${first.cssImport}';
3078
3135
 
@@ -3082,7 +3139,7 @@ Install in your app (paths are relative to a file in src/; adjust if yours lives
3082
3139
 
3083
3140
  Or with a <link> tag:
3084
3141
 
3085
- import { Theme } from '@astryxdesign/core';
3142
+ import { Theme } from '@astryxdesign/core/theme';
3086
3143
  import { ${first.exportName} } from '${first.jsImport}';
3087
3144
 
3088
3145
  <link rel="stylesheet" href="${first.cssImport}" />
@@ -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. A relative path must stay within cwd.',
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 relative `out` path resolves outside cwd (including through a symlink)',
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',
@@ -0,0 +1,120 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import transform from '../migrate-list-edge-compensation-to-edge.mjs';
5
+
6
+ async function applyTransform(source) {
7
+ const jscodeshift = (await import('jscodeshift')).default;
8
+ const j = jscodeshift.withParser('tsx');
9
+ const api = {jscodeshift: j, stats: () => {}, report: () => {}};
10
+ return transform({source, path: 'test.tsx'}, api) ?? source;
11
+ }
12
+
13
+ describe('migrate-list-edge-compensation-to-edge', () => {
14
+ it("renames edgeCompensation='inline' to edge='outset'", async () => {
15
+ const output =
16
+ await applyTransform(`import {List, ListItem} from '@astryxdesign/core/List';
17
+ const a = <List edgeCompensation="inline"><ListItem label="a" /></List>;
18
+ const b = <List hasDividers edgeCompensation={'inline'} />;`);
19
+
20
+ expect(output).not.toContain('edgeCompensation');
21
+ expect(output.match(/edge='outset'/g)).toHaveLength(2);
22
+ expect(output).toContain('hasDividers');
23
+ });
24
+
25
+ it('follows aliases and the package root import', async () => {
26
+ const output =
27
+ await applyTransform(`import {List as Rows} from '@astryxdesign/core';
28
+ const a = <Rows edgeCompensation="inline" />;`);
29
+
30
+ expect(output).toContain("<Rows edge='outset' />");
31
+ });
32
+
33
+ it('drops edgeCompensation beside an edge string, whatever its value', async () => {
34
+ const output =
35
+ await applyTransform(`import {List} from '@astryxdesign/core/List';
36
+ const a = <List edge="bleed" edgeCompensation="inline" />;
37
+ const b = <List edge={'flush'} edgeCompensation={mode} />;
38
+ const c = <List {...rest} edge="padded" edgeCompensation="inline" />;`);
39
+
40
+ // c: the spread comes before edge, so it cannot override it.
41
+ expect(output).toContain('edge="bleed"');
42
+ expect(output).toContain("edge={'flush'}");
43
+ expect(output).toContain('edge="padded"');
44
+ expect(output).not.toContain('edgeCompensation');
45
+ expect(output).not.toContain('TODO');
46
+ });
47
+
48
+ it('leaves a TODO where edge may be undefined at runtime', async () => {
49
+ const output =
50
+ await applyTransform(`import {List} from '@astryxdesign/core/List';
51
+ const a = <List edge={edge} edgeCompensation="inline" />;
52
+ const b = <List edge="flush" {...rest} edgeCompensation="inline" />;
53
+ const c = <List edge={props.edge} edgeCompensation={mode} />;`);
54
+
55
+ // a and c: a computed edge falls back to the old prop when undefined.
56
+ // b: the spread after edge may set it to undefined.
57
+ expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
58
+ expect(output.match(/edgeCompensation=/g)).toHaveLength(3);
59
+ expect(output).toContain('edge={edge}');
60
+ expect(output).toContain('edge={props.edge}');
61
+ });
62
+
63
+ it('leaves a TODO on a dynamic value and keeps it working', async () => {
64
+ const output =
65
+ await applyTransform(`import {List} from '@astryxdesign/core/List';
66
+ const a = <List edgeCompensation={mode} />;
67
+ const b = <List edgeCompensation={flag ? 'inline' : undefined} />;`);
68
+
69
+ expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(2);
70
+ expect(output).toContain('edgeCompensation={mode}');
71
+ expect(output).toContain("edgeCompensation={flag ? 'inline' : undefined}");
72
+ });
73
+
74
+ it('keeps edgeCompensation with a TODO when the List also spreads props', async () => {
75
+ const output =
76
+ await applyTransform(`import {List} from '@astryxdesign/core/List';
77
+ const a = <List {...rest} edgeCompensation="inline" />;
78
+ const b = <List edgeCompensation="inline" {...rest} />;
79
+ const c = <List {...rest} edgeCompensation={mode} />;
80
+ const d = <List {...props} />;
81
+ const e = <List {...rest} edge="padded" />;`);
82
+
83
+ // a: an edge in the spread wins today, and a new edge would override
84
+ // it. b: the spread may override either prop. c: dynamic value and a
85
+ // spread, one TODO. d: no evidence of the old prop, so unchanged; its
86
+ // @deprecated type reports a typed use where the props are written.
87
+ // e: no old prop.
88
+ expect(output.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
89
+ const code = output.replace(/\/\*[\s\S]*?\*\//g, '');
90
+ expect(code).not.toContain("edge='outset'");
91
+ expect(code.match(/edgeCompensation="inline"/g)).toHaveLength(2);
92
+ expect(output).toContain('edgeCompensation={mode}');
93
+ expect(output).toContain('const d = <List {...props} />;');
94
+ expect(output).toContain('edge="padded"');
95
+ });
96
+
97
+ it('leaves other components, other packages, and unrelated spreads alone', async () => {
98
+ const source = `import {List} from '@astryxdesign/core/List';
99
+ import {TabList} from '@astryxdesign/core/TabList';
100
+ import {List as Other} from 'some-other-package';
101
+ const a = <TabList edgeCompensation="inline" {...props} />;
102
+ const b = <Other edgeCompensation="inline" {...props} />;
103
+ const c = <div {...props} />;`;
104
+ expect(await applyTransform(source)).toBe(source);
105
+ });
106
+
107
+ it('changes nothing on a second run', async () => {
108
+ const once =
109
+ await applyTransform(`import {List} from '@astryxdesign/core/List';
110
+ const a = <List edgeCompensation="inline" />;
111
+ const b = <List edgeCompensation={mode} />;
112
+ const c = <List {...props} edgeCompensation="inline" />;
113
+ const d = <List edge="flush" edgeCompensation="inline" />;
114
+ const e = <List edge={edge} edgeCompensation="inline" />;`);
115
+ const twice = await applyTransform(once);
116
+
117
+ expect(twice).toBe(once);
118
+ expect(twice.match(/TODO\(astryx upgrade\)/g)).toHaveLength(3);
119
+ });
120
+ });
@@ -7,4 +7,14 @@
7
7
  * this file into the resolved version folder.
8
8
  */
9
9
 
10
- export default [];
10
+ import migrateListEdgeCompensationToEdge, {
11
+ meta as migrateListEdgeCompensationToEdgeMeta,
12
+ } from './migrate-list-edge-compensation-to-edge.mjs';
13
+
14
+ export default [
15
+ {
16
+ name: 'migrate-list-edge-compensation-to-edge',
17
+ transform: migrateListEdgeCompensationToEdge,
18
+ meta: migrateListEdgeCompensationToEdgeMeta,
19
+ },
20
+ ];