@astryxdesign/cli 0.7.11 → 0.7.12-canary.ccb4a6d

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 (68) hide show
  1. package/api/build/kit/rank.mjs +21 -4
  2. package/api/build/kit/words.d.mts +1 -0
  3. package/api/build/kit/words.mjs +1 -1
  4. package/api/icon/_adapter.d.mts +3 -2
  5. package/api/icon/_adapter.mjs +8 -2
  6. package/api/icon/_authoring.d.mts +1 -0
  7. package/api/icon/_authoring.mjs +586 -66
  8. package/api/icon/_default-look.d.mts +155 -0
  9. package/api/icon/_default-look.mjs +474 -0
  10. package/api/icon/_records.d.mts +1 -1
  11. package/api/icon/_records.mjs +1 -0
  12. package/api/icon/add/add.mjs +224 -38
  13. package/api/icon/icon.type.d.mts +22 -2
  14. package/api/icon/icon.type.mjs +6 -2
  15. package/api/icon/iconAdd.doc.mjs +2 -2
  16. package/api/icon/iconNewDefinition.doc.mjs +3 -2
  17. package/api/icon/iconNewSet.doc.mjs +4 -4
  18. package/api/icon/search/search.mjs +10 -9
  19. package/api/integration/add-helpers.d.mts +11 -1
  20. package/api/integration/add-helpers.mjs +5 -2
  21. package/api/integration/authoring-checks.d.mts +0 -2
  22. package/api/integration/authoring-checks.mjs +1 -6
  23. package/api/logger.mjs +2 -1
  24. package/api/swizzle/_adapter.d.mts +1 -0
  25. package/api/swizzle/_adapter.mjs +4 -1
  26. package/api/swizzle/_moduleGraph.d.mts +171 -0
  27. package/api/swizzle/_moduleGraph.mjs +795 -0
  28. package/api/swizzle/copy/copy.d.mts +2 -43
  29. package/api/swizzle/copy/copy.mjs +195 -314
  30. package/api/swizzle/swizzle.d.mts +0 -2
  31. package/api/swizzle/swizzle.doc.mjs +9 -4
  32. package/api/swizzle/swizzle.mjs +3 -7
  33. package/api/swizzle/swizzle.type.d.mts +2 -1
  34. package/api/swizzle/swizzle.type.mjs +3 -2
  35. package/assets/discover/official-packages.json +20 -0
  36. package/assets/docs/tree/icon-sets.doc.mjs +11 -5
  37. package/assets/templates/blocks/components/Table/TableRowIndexTable.doc.mjs +1 -1
  38. package/assets/templates/blocks/components/Table/TableRowIndexTable.tsx +2 -2
  39. package/assets/templates/pages/course-detail/page.tsx +699 -0
  40. package/assets/templates/pages/course-detail/template.doc.mjs +37 -0
  41. package/assets/templates/pages/file-explorer/page.tsx +103 -79
  42. package/assets/templates/pages/table-grouped/page.tsx +160 -232
  43. package/assets/templates/pages/table-grouped/template.doc.mjs +0 -2
  44. package/assets/templates/pages/theme-showcase/page.tsx +724 -792
  45. package/assets/templates/pages/theme-showcase/template.doc.mjs +4 -5
  46. package/authoring/doctypes/template/type.ts +1 -0
  47. package/clients/cli/commands/experimental/icon.mjs +31 -10
  48. package/clients/cli/commands/experimental-icon-add.doc.mjs +1 -1
  49. package/clients/cli/commands/experimental-icon-new-definition.doc.mjs +2 -1
  50. package/clients/cli/commands/experimental-icon-new-set.doc.mjs +2 -2
  51. package/clients/cli/commands/swizzle.doc.mjs +8 -3
  52. package/clients/cli/commands/swizzle.mjs +21 -9
  53. package/foundation/discovery/component-loader.d.mts +1 -1
  54. package/foundation/discovery/doc-types.mjs +9 -2
  55. package/foundation/discovery/icon-ranking.d.mts +27 -3
  56. package/foundation/discovery/icon-ranking.mjs +125 -8
  57. package/foundation/discovery/icon-set-checks.mjs +44 -35
  58. package/foundation/discovery/icon-set-discovery.d.mts +13 -0
  59. package/foundation/discovery/icon-set-discovery.mjs +25 -0
  60. package/foundation/discovery/icon-set-writer.d.mts +238 -16
  61. package/foundation/discovery/icon-set-writer.mjs +676 -79
  62. package/foundation/discovery/module-exports.d.mts +3 -2
  63. package/foundation/discovery/module-exports.mjs +167 -37
  64. package/foundation/fs/paths.d.mts +2 -1
  65. package/foundation/fs/paths.mjs +3 -4
  66. package/foundation/text/shell-arg.d.mts +14 -0
  67. package/foundation/text/shell-arg.mjs +17 -0
  68. package/package.json +6 -6
@@ -2,13 +2,14 @@
2
2
 
3
3
  /**
4
4
  * @file swizzle.copy leaf — copy a component's source into the consumer project
5
- * for customization, rewriting escaping relative imports to the OWNER package's
6
- * subpaths.
5
+ * for customization: what the OWNER package exports is imported from it, and
6
+ * every internal module the component needs is copied beside it.
7
7
  *
8
8
  * Side-effecting: writes files and returns a `swizzle.copy` receipt describing
9
- * what it did. Shared core discovery + component listing come from
10
- * api/swizzle/_adapter.mjs; everything here (owner resolution, the copy, import
11
- * rewriting, StyleX detection, maintainer feedback) is copy-specific. Errors
9
+ * what it did. Shared core discovery, component listing and the module graph
10
+ * (which modules the copy holds and how each import reads) come from
11
+ * api/swizzle/_adapter.mjs; owner resolution, where each file lands, reuse of
12
+ * existing helpers, StyleX detection and maintainer feedback are here. Errors
12
13
  * throw AstryxError (stable code + suggestions). All human prose /
13
14
  * package-manager prefixing lives in the CLI renderer.
14
15
  */
@@ -17,11 +18,15 @@ import * as fs from 'node:fs';
17
18
  import * as path from 'node:path';
18
19
  import {
19
20
  checkGhCli,
21
+ planCopy,
22
+ relativeSpecifierBetween,
23
+ renderFile,
20
24
  resolveComponentReplacement,
21
25
  resolveCore,
22
26
  } from '../_adapter.mjs';
23
27
  import {assertWithin, sanitizeName, PathSafetyError} from '../../../foundation/fs/path-safety.mjs';
24
28
  import {Project} from '../../../foundation/config/project.mjs';
29
+ import {findPackageRoot} from '../../../foundation/fs/paths.mjs';
25
30
  import {
26
31
  CORE_PACKAGE,
27
32
  docStem,
@@ -35,212 +40,71 @@ import {AstryxError, writeFailed} from '../../error.mjs';
35
40
  /** Default issue tracker for maintainer feedback after swizzling. */
36
41
  const DEFAULT_ISSUES_URL = 'https://github.com/meta/astryx/issues/new';
37
42
 
38
- /**
39
- * Rewrite relative imports that point outside the component directory to use
40
- * the OWNER package's subpaths. Imports within the copied directory (./x) are
41
- * left untouched.
42
- *
43
- * e.g. with ownerPackage '@astryxdesign/core':
44
- * '../theme/tokens.stylex' -> '@astryxdesign/core/theme/tokens.stylex'
45
- * '../utils/mergeProps' -> '@astryxdesign/core/utils'
46
- * '../../locales/en.json' -> '@astryxdesign/core/locales/en.json'
47
- * import('../Tooltip/Tooltip') -> import('@astryxdesign/core/Tooltip')
48
- *
49
- * Most sibling dirs are barrels, so the top-dir collapse (`../utils/x` ->
50
- * `<pkg>/utils`) resolves. Two shapes must NOT be collapsed to the top dir:
51
- * - Asset files (`.json`, `.css`): exported by full subpath (e.g.
52
- * `./locales/*.json`, `./reset.css`), and collapsing a two-levels-up
53
- * `../../locales/x.json` would emit the invalid `<pkg>/..`.
54
- * - StyleX modules (`*.stylex`): the StyleX compiler needs the real module
55
- * path so it can resolve the styles at compile time. The barrel re-export
56
- * loses the module identity. Core exports every `.stylex` file by its
57
- * deep subpath (e.g. `./utils/focusOutline.stylex`,
58
- * `./theme/tokens.stylex`).
59
- *
60
- * @param {string} content
61
- * @param {string} [ownerPackage]
62
- * @param {(importPath: string) => boolean} [keep] Imports to leave relative:
63
- * the modules the copy carries in the same layout (see `sharedModulesOf`).
64
- */
65
- export function rewriteImports(content, ownerPackage = CORE_PACKAGE, keep = () => false) {
66
- /** @param {string} importPath */
67
- const mapTarget = importPath => {
68
- // A module the copy carries beside the component keeps its relative path.
69
- if (keep(importPath)) {
70
- return importPath;
71
- }
72
- // Strip ALL leading `../` so a two-levels-up path never yields `<pkg>/..`.
73
- const rest = importPath.replace(/^(?:\.\.\/)+/, '');
74
- const parts = rest.split('/');
75
- const last = parts[parts.length - 1];
76
- // Asset files are resolved by exact export / wildcard, never a barrel.
77
- if (/\.(?:json|css)$/.test(last)) {
78
- return `${ownerPackage}/${rest}`;
79
- }
80
- // StyleX modules need the deep path so the compiler can resolve them.
81
- if (/\.stylex(?:\.[cm]?[jt]sx?)?$/.test(last)) {
82
- return `${ownerPackage}/${rest}`;
83
- }
84
- return `${ownerPackage}/${parts[0]}`;
85
- };
86
- return content
87
- .replace(
88
- /(from\s+['"])(\.\.\/[^'"]+)(['"])/g,
89
- (_m, prefix, importPath, suffix) =>
90
- `${prefix}${mapTarget(importPath)}${suffix}`,
91
- )
92
- .replace(
93
- /(import\(\s*['"])(\.\.\/[^'"]+)(['"])/g,
94
- (_m, prefix, importPath, suffix) =>
95
- `${prefix}${mapTarget(importPath)}${suffix}`,
96
- );
97
- }
98
-
99
43
  /** A private data attribute's owner module (`architecture:public-component-api` INV23). */
100
44
  const OWNER_MODULE = /^privateAttributes\/[^/]+\.stylex$/;
101
45
  /** A StyleX module, with or without its file extension. */
102
46
  const STYLEX_MODULE = /\.stylex(?:\.[cm]?[jt]sx?)?$/;
103
- /** The extensions an extensionless import may name, in resolution order. */
104
- const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mjs', '.js'];
47
+ /** @param {string} packageDir */
48
+ function packageVersionOf(packageDir) {
49
+ try {
50
+ return String(JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf-8')).version ?? '0');
51
+ } catch {
52
+ return '0';
53
+ }
54
+ }
105
55
 
106
56
  /**
107
- * The relative import specifiers in a module's source.
108
- * @param {string} content
57
+ * Where a component's module graph starts: its folder's index, or else every
58
+ * source file at the top of the folder.
59
+ * @param {string} componentDir
109
60
  * @returns {string[]}
110
61
  */
111
- function relativeImportsOf(content) {
112
- const found = [];
113
- for (const re of [
114
- /from\s+['"](\.\.?\/[^'"]+)['"]/g,
115
- /import\(\s*['"](\.\.?\/[^'"]+)['"]/g,
116
- ]) {
117
- for (const m of content.matchAll(re)) found.push(m[1]);
62
+ function entryFilesOf(componentDir) {
63
+ for (const name of ['index.ts', 'index.tsx', 'index.mjs', 'index.js']) {
64
+ const file = path.join(componentDir, name);
65
+ if (fs.existsSync(file)) return [file];
118
66
  }
119
- return found;
67
+ return fs
68
+ .readdirSync(componentDir)
69
+ .filter(f => /\.[cm]?[jt]sx?$/.test(f) && !/\.(?:test|spec|stories|doc)\./.test(f) && !/\.d\.[cm]?ts$/.test(f))
70
+ .map(f => path.join(componentDir, f))
71
+ .filter(f => fs.statSync(f).isFile());
120
72
  }
121
73
 
122
74
  /**
123
- * The source file an import resolves to under `srcRoot`, as a path relative to
124
- * `srcRoot` without its extension (`privateAttributes/press.stylex`), and the
125
- * file's own relative path; null when it resolves outside `srcRoot` or to no
126
- * file.
75
+ * A module path relative to the source root, without its extension.
127
76
  * @param {string} srcRoot
128
- * @param {string} fromDir directory of the importing file, relative to srcRoot
129
- * @param {string} importPath
130
- * @returns {{module: string, file: string} | null}
77
+ * @param {string} file
131
78
  */
132
- function resolveSourceModule(srcRoot, fromDir, importPath) {
133
- const module = path.posix.normalize(path.posix.join(fromDir, importPath));
134
- if (module.startsWith('..') || path.posix.isAbsolute(module)) return null;
135
- for (const ext of ['', ...SOURCE_EXTENSIONS]) {
136
- const file = module + ext;
137
- const abs = path.join(srcRoot, file);
138
- if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
139
- return {
140
- module: ext ? module : module.replace(/\.[cm]?[jt]sx?$/, ''),
141
- file,
142
- };
143
- }
144
- }
145
- return null;
79
+ function toModulePath(srcRoot, file) {
80
+ return toPosix(path.relative(srcRoot, file)).replace(/\.[cm]?[jt]sx?$/, '');
146
81
  }
147
82
 
148
- /**
149
- * The subpaths the owner package exports (`./theme/tokens.stylex`), read from
150
- * the nearest package.json above its source root.
151
- * @param {string} srcRoot
152
- * @returns {Set<string>}
153
- */
154
- function exportedSubpathsOf(srcRoot) {
155
- let dir = srcRoot;
156
- for (;;) {
157
- const manifest = path.join(dir, 'package.json');
158
- if (fs.existsSync(manifest)) {
159
- try {
160
- const {exports} = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
161
- return new Set(
162
- exports && typeof exports === 'object' ? Object.keys(exports) : [],
163
- );
164
- } catch {
165
- return new Set();
166
- }
167
- }
168
- const parent = path.dirname(dir);
169
- if (parent === dir) return new Set();
170
- dir = parent;
171
- }
83
+ /** @param {string} file */
84
+ function toPosix(file) {
85
+ return file.split(path.sep).join('/');
172
86
  }
173
87
 
174
88
  /**
175
- * The modules a copy carries beside the component, in the package's own
176
- * layout under the output directory (`spec:AST-110` FR4): each private
177
- * attribute owner module the component imports, and every unexported StyleX
178
- * module such a carried module imports, transitively. The component's own
179
- * imports of them, and theirs of each other, stay relative.
180
- *
181
- * @param {string} srcRoot the owner package's source root
182
- * @param {string} componentDir
183
- * @param {string[]} componentFiles files of the component that are copied
184
- * @returns {Map<string, string>} module path (relative to srcRoot, no
185
- * extension) -> its file path relative to srcRoot
89
+ * @param {string} target
90
+ * @param {string} root
186
91
  */
187
- export function sharedModulesOf(srcRoot, componentDir, componentFiles) {
188
- const exported = exportedSubpathsOf(srcRoot);
189
- const componentRel = path
190
- .relative(srcRoot, componentDir)
191
- .split(path.sep)
192
- .join('/');
193
- /** @type {Map<string, string>} */
194
- const carried = new Map();
195
- /** @type {Array<{fromDir: string, content: string, owner: boolean}>} */
196
- const queue = componentFiles
197
- .filter(f => /\.[cm]?[jt]sx?$/.test(f))
198
- .map(f => ({
199
- fromDir: componentRel,
200
- content: fs.readFileSync(path.join(componentDir, f), 'utf-8'),
201
- owner: false,
202
- }));
203
- while (queue.length > 0) {
204
- const {fromDir, content, owner} = /** @type {typeof queue[number]} */ (
205
- queue.shift()
206
- );
207
- for (const importPath of relativeImportsOf(content)) {
208
- const target = resolveSourceModule(srcRoot, fromDir, importPath);
209
- if (target == null || carried.has(target.module)) continue;
210
- if (target.module.startsWith(componentRel + '/')) continue;
211
- const isOwner = OWNER_MODULE.test(target.module);
212
- // A carried module's unexported StyleX dependency travels with it; a
213
- // component's own imports of other modules are rewritten as before.
214
- const isPrivateStyle =
215
- owner &&
216
- STYLEX_MODULE.test(target.module) &&
217
- !exported.has(`./${target.module}`);
218
- if (!isOwner && !isPrivateStyle) continue;
219
- carried.set(target.module, target.file);
220
- queue.push({
221
- fromDir: path.posix.dirname(target.module),
222
- content: fs.readFileSync(path.join(srcRoot, target.file), 'utf-8'),
223
- owner: true,
224
- });
225
- }
226
- }
227
- return carried;
92
+ function isWithin(target, root) {
93
+ const rel = path.relative(root, target);
94
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
228
95
  }
229
96
 
230
97
  /**
231
- * A `keep` predicate for `rewriteImports`: true for an import that names one
232
- * of the carried modules from a file in `fromDir`.
233
- * @param {Map<string, string>} carried
234
- * @param {string} fromDir relative to the source root
235
- * @returns {(importPath: string) => boolean}
98
+ * A helper's file name with the owner version before its extension:
99
+ * `layerStack.ts` -> `layerStack.v0.7.9.ts`, or `layerStack.v0.7.9-2.ts`.
100
+ * @param {string} dest
101
+ * @param {string} version
102
+ * @param {number} n
236
103
  */
237
- function keepCarried(carried, fromDir) {
238
- return importPath => {
239
- const module = path.posix
240
- .normalize(path.posix.join(fromDir, importPath))
241
- .replace(/\.[cm]?[jt]sx?$/, '');
242
- return carried.has(module);
243
- };
104
+ function versionedPath(dest, version, n) {
105
+ const ext = /(\.stylex)?\.[cm]?[jt]sx?$/.exec(dest)?.[0] ?? path.extname(dest);
106
+ const stem = dest.slice(0, dest.length - ext.length);
107
+ return `${stem}.v${version}${n > 1 ? `-${n}` : ''}${ext}`;
244
108
  }
245
109
 
246
110
  /**
@@ -339,20 +203,10 @@ function confineWrite(target, cwd, options) {
339
203
  }
340
204
  }
341
205
 
342
- /** @param {string} file */
343
- function isExcludedFromCopy(file) {
344
- return (
345
- file.includes('.test.') ||
346
- file.includes('.doc.') ||
347
- file === 'README.md' ||
348
- // Browser test harnesses import dev-only packages a consumer cannot resolve.
349
- /\.spec\.[cm]?[jt]sx?$/.test(file)
350
- );
351
- }
352
-
353
206
  /**
354
207
  * Copy one component's source into the consumer project for customization,
355
- * rewriting escaping relative imports to the owner package's subpaths.
208
+ * with the internal modules it needs and its imports of the owner package's
209
+ * public paths.
356
210
  *
357
211
  * @param {string} component bare or XDS-prefixed component name
358
212
  * @param {{cwd?: string, output?: string, package?: string, overwrite?: boolean}} [options]
@@ -463,58 +317,146 @@ export async function swizzleCopy(component, options = {}) {
463
317
  // writes anything.
464
318
  const feedback = buildFeedback(dirName, owner.issuesUrl);
465
319
 
466
- // Pre-flight path and overwrite checks before any mkdir/writeFile.
467
- const sourceFiles = fs.readdirSync(componentDir).filter(file => {
468
- if (isExcludedFromCopy(file)) return false;
469
- return fs.statSync(path.join(componentDir, file)).isFile();
320
+ // The copy follows the component's module graph through the whole source
321
+ // root: `src/` when the package has one around the component, else the
322
+ // package itself.
323
+ const packageDir = findPackageRoot(componentDir) ?? componentDir;
324
+ const packageSrc = path.join(packageDir, 'src');
325
+ const inPackageSrc = componentDir.startsWith(packageSrc + path.sep);
326
+ const graphRoot = inPackageSrc ? packageSrc : packageDir;
327
+ // Carried modules keep their layout relative to this folder.
328
+ const srcRoot = inPackageSrc ? packageSrc : path.dirname(componentDir);
329
+ const plan = planCopy({
330
+ packageDir,
331
+ srcRoot: graphRoot,
332
+ componentDir,
333
+ ownerPackage: owner.ownerPackage,
334
+ entries: entryFilesOf(componentDir),
470
335
  });
471
- for (const file of sourceFiles) {
472
- confineWrite(path.join(outputDir, file), cwd, {
473
- allowAbsolute: true,
474
- label: 'output file',
475
- });
336
+ const ownerVersion = packageVersionOf(packageDir);
337
+
338
+ // Carried modules keep the package's layout around the component folder.
339
+ const depth = path.relative(srcRoot, componentDir).split(path.sep).length;
340
+ const shiftedRoot = path.resolve(outputDir, ...Array(depth).fill('..'));
341
+ const layoutRoot = isWithin(shiftedRoot, outputBase) ? shiftedRoot : outputBase;
342
+ /** @param {string} file */
343
+ const isComponentFile = file => file.startsWith(componentDir + path.sep);
344
+ // A module above that folder, such as `lib/` beside an integration's
345
+ // `components/`, goes under the output folder by its path in the package.
346
+ /** @param {string} file */
347
+ const layoutDestOf = file => {
348
+ if (isComponentFile(file)) {
349
+ return path.join(outputDir, path.relative(componentDir, file));
350
+ }
351
+ if (file.startsWith(srcRoot + path.sep)) {
352
+ return path.join(layoutRoot, path.relative(srcRoot, file));
353
+ }
354
+ return null;
355
+ };
356
+ const allFiles = [...plan.files.keys()];
357
+ // Such a module takes a folder named after the package when its own path
358
+ // is already a destination, so two modules never share one file.
359
+ const takenDests = new Set(
360
+ allFiles.map(layoutDestOf).filter(dest => dest != null),
361
+ );
362
+ /** @type {Map<string, string>} */
363
+ const rootDests = new Map();
364
+ for (const file of [...allFiles].sort()) {
365
+ if (layoutDestOf(file) != null) continue;
366
+ const packagePath = path.relative(graphRoot, file);
367
+ let dest = path.join(outputBase, packagePath);
368
+ for (let n = 1; takenDests.has(dest); n++) {
369
+ const folder = n === 1 ? path.basename(packageDir) : `${path.basename(packageDir)}-${n}`;
370
+ dest = path.join(outputBase, folder, packagePath);
371
+ }
372
+ takenDests.add(dest);
373
+ rootDests.set(file, dest);
374
+ }
375
+ /** @param {string} file */
376
+ const baseDestOf = file =>
377
+ layoutDestOf(file) ?? /** @type {string} */ (rootDests.get(file));
378
+
379
+ const componentFiles = allFiles.filter(isComponentFile);
380
+ const helpers = allFiles.filter(f => !isComponentFile(f));
381
+ // A private attribute owner module and the unexported style modules it
382
+ // imports travel whole and are replaced only with --overwrite.
383
+ const ownerGroup = new Set();
384
+ const ownerQueue = helpers.filter(f =>
385
+ OWNER_MODULE.test(toModulePath(srcRoot, f)),
386
+ );
387
+ while (ownerQueue.length > 0) {
388
+ const file = /** @type {string} */ (ownerQueue.shift());
389
+ if (ownerGroup.has(file)) continue;
390
+ ownerGroup.add(file);
391
+ for (const dep of plan.dependsOn.get(file) ?? []) {
392
+ if (!isComponentFile(dep) && STYLEX_MODULE.test(toModulePath(srcRoot, dep))) {
393
+ ownerQueue.push(dep);
394
+ }
395
+ }
476
396
  }
477
- // The owner modules the component imports, and their unexported StyleX
478
- // dependencies, travel with it in the package's layout (spec:AST-110 FR4).
479
- const srcRoot = path.dirname(componentDir);
480
- const componentRel = path
481
- .relative(srcRoot, componentDir)
482
- .split(path.sep)
483
- .join('/');
484
- const carried = sharedModulesOf(srcRoot, componentDir, sourceFiles);
485
- const shared = [...carried.values()].map(file => {
486
- const content = rewriteImports(
487
- fs.readFileSync(path.join(srcRoot, file), 'utf-8'),
488
- owner.ownerPackage,
489
- keepCarried(carried, path.posix.dirname(file)),
397
+
398
+ /** @type {Map<string, string>} helper -> the file it is written to instead */
399
+ const redirected = new Map();
400
+ /** @param {string} file */
401
+ const destOf = file => redirected.get(file) ?? baseDestOf(file);
402
+ /** @param {string} file */
403
+ const render = file =>
404
+ renderFile(/** @type {any} */ (plan.files.get(file)), choice =>
405
+ typeof choice === 'string'
406
+ ? choice
407
+ : relativeSpecifierBetween(destOf(choice.from), destOf(choice.to)),
490
408
  );
491
- const dest = confineWrite(path.join(outputBase, file), cwd, {
409
+ /** @param {string} dest */
410
+ const readExisting = dest => {
411
+ if (!fs.existsSync(dest)) return null;
412
+ try {
413
+ return fs.readFileSync(dest, 'utf-8');
414
+ } catch {
415
+ return '';
416
+ }
417
+ };
418
+ // A helper an earlier swizzle wrote is reused when it is the same. A
419
+ // different file is never overwritten: this copy moves its own helper to
420
+ // the next free version-suffixed name. A move changes the import in every
421
+ // helper that imports it, so the check repeats until nothing moves.
422
+ /** @type {Map<string, number>} helper -> how many names it tried */
423
+ const attempts = new Map();
424
+ for (let moved = true; moved; ) {
425
+ moved = false;
426
+ for (const file of helpers) {
427
+ if (ownerGroup.has(file)) continue;
428
+ const existing = readExisting(destOf(file));
429
+ if (existing == null || existing === render(file)) continue;
430
+ const attempt = (attempts.get(file) ?? 0) + 1;
431
+ attempts.set(file, attempt);
432
+ redirected.set(
433
+ file,
434
+ versionedPath(baseDestOf(file), ownerVersion, attempt),
435
+ );
436
+ moved = true;
437
+ }
438
+ }
439
+
440
+ // The component's own files come first, then the helpers it carries.
441
+ const outputs = [...componentFiles, ...helpers].map(file => {
442
+ const dest = confineWrite(destOf(file), cwd, {
492
443
  allowAbsolute: true,
493
444
  label: 'output file',
494
445
  });
495
- // Something that is not a readable file in its place counts as changed.
496
- let existing = null;
497
- if (fs.existsSync(dest)) {
498
- try {
499
- existing = fs.readFileSync(dest, 'utf-8');
500
- } catch {
501
- existing = '';
502
- }
503
- }
504
- // A copy another component's swizzle already wrote is reused when it is
505
- // the same; a changed one is a file this run would overwrite.
446
+ const content = render(file);
447
+ const existing = readExisting(dest);
506
448
  return {
507
449
  file,
508
450
  dest,
509
451
  content,
510
- unchanged: existing === content,
452
+ component: isComponentFile(file),
453
+ owner: ownerGroup.has(file),
511
454
  exists: existing != null,
455
+ unchanged: existing === content,
512
456
  };
513
457
  });
514
- const existingFiles = sourceFiles.filter(f =>
515
- fs.existsSync(path.join(outputDir, f)),
516
- );
517
- const changedShared = shared.filter(s => s.exists && !s.unchanged);
458
+ const existingFiles = outputs.filter(o => o.component && o.exists);
459
+ const changedShared = outputs.filter(o => o.owner && o.exists && !o.unchanged);
518
460
  if ((existingFiles.length > 0 || changedShared.length > 0) && !overwrite) {
519
461
  const relOutputForMsg = path.relative(cwd, outputDir) || '.';
520
462
  const sharedNote =
@@ -531,109 +473,48 @@ export async function swizzleCopy(component, options = {}) {
531
473
  );
532
474
  }
533
475
 
534
-
535
476
  const outputDirExisted = fs.existsSync(outputDir);
536
- try {
537
- fs.mkdirSync(outputDir, {recursive: true});
538
- } catch (err) {
539
- throw writeFailed(outputDir, cwd, err);
540
- }
541
-
542
- const files = fs.readdirSync(componentDir);
543
- let copied = 0;
544
- let usesStyleX = false;
545
- // A copy that fails part-way undoes what it already wrote, so the report is
546
- // true and a retry does not trip over half a component. Each entry keeps
547
- // the bytes the file had before this run (null when the copy created it).
477
+ /** @type {string[]} folders this run created, outermost first */
478
+ const createdDirs = [];
548
479
  /** @type {Array<{dest: string, original: Buffer|null}>} */
549
480
  const written = [];
550
- for (const file of files) {
551
- if (isExcludedFromCopy(file)) continue;
552
- const srcPath = path.join(componentDir, file);
553
- if (!fs.statSync(srcPath).isFile()) continue;
554
- let content = fs.readFileSync(srcPath, 'utf-8');
555
- if (file.endsWith('.ts') || file.endsWith('.tsx')) {
556
- content = rewriteImports(content, owner.ownerPackage, keepCarried(carried, componentRel));
557
- }
558
- if (
559
- (file.endsWith('.ts') || file.endsWith('.tsx')) &&
560
- content.includes('@stylexjs/stylex')
561
- ) {
562
- usesStyleX = true;
563
- }
564
- const dest = path.join(outputDir, file);
565
- try {
566
- // The snapshot read is guarded too: a destination that cannot be read
567
- // back (no permission, or a directory with this name) must still undo
568
- // the earlier writes and report ERR_WRITE_FAILED.
569
- const original = fs.existsSync(dest) ? fs.readFileSync(dest) : null;
570
- written.push({dest, original});
571
- fs.writeFileSync(dest, content);
572
- } catch (err) {
573
- const unrestored = undoCopy(written);
574
- if (!outputDirExisted) {
575
- try {
576
- fs.rmdirSync(outputDir);
577
- } catch {
578
- // Not empty (something could not be undone), or already gone.
579
- }
580
- }
581
- throw writeFailed(dest, cwd, err, unrestored);
582
- }
583
- copied++;
584
- }
585
-
586
- /** @type {string[]} folders this run created for carried modules, outermost first */
587
- const createdDirs = [];
588
- for (const module of shared) {
589
- // An identical copy is already in place: it is part of this copy, unwritten.
590
- if (module.unchanged) {
481
+ let copied = 0;
482
+ for (const output of outputs) {
483
+ // An identical file is already in place: it is part of this copy, unwritten.
484
+ if (output.unchanged) {
591
485
  copied++;
592
486
  continue;
593
487
  }
594
488
  try {
595
- const original = fs.existsSync(module.dest)
596
- ? fs.readFileSync(module.dest)
597
- : null;
598
- const firstCreated = fs.mkdirSync(path.dirname(module.dest), {recursive: true});
489
+ const original = fs.existsSync(output.dest) ? fs.readFileSync(output.dest) : null;
490
+ const firstCreated = fs.mkdirSync(path.dirname(output.dest), {recursive: true});
599
491
  if (firstCreated != null) createdDirs.push(firstCreated);
600
- written.push({dest: module.dest, original});
601
- fs.writeFileSync(module.dest, module.content);
492
+ written.push({dest: output.dest, original});
493
+ fs.writeFileSync(output.dest, output.content);
602
494
  copied++;
603
495
  } catch (err) {
604
496
  const unrestored = undoCopy(written);
605
- // Remove the folders this run created, as a failed component-file write
606
- // does, so a retry finds the output as it was.
607
497
  for (const dir of [...createdDirs].reverse()) {
608
498
  fs.rmSync(dir, {recursive: true, force: true});
609
499
  }
610
500
  if (!outputDirExisted) {
611
501
  fs.rmSync(outputDir, {recursive: true, force: true});
612
502
  }
613
- throw writeFailed(module.dest, cwd, err, unrestored);
503
+ throw writeFailed(output.dest, cwd, err, unrestored);
614
504
  }
615
505
  }
616
- if (shared.length > 0) usesStyleX = true;
506
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, {recursive: true});
617
507
 
618
508
  const relOutput = path.relative(cwd, outputDir);
619
- const copiedFiles = files.filter(
620
- f =>
621
- !isExcludedFromCopy(f) &&
622
- fs.statSync(path.join(componentDir, f)).isFile(),
623
- );
624
509
  /** @type {import('../swizzle.type.mjs').SwizzleCopyResponse['data']} */
625
510
  const data = {
626
511
  component: dirName,
627
512
  package: owner.package,
628
513
  outputDir: relOutput,
629
514
  filesCopied: copied,
630
- // The owner modules and their unexported style dependencies the copy
631
- // carries beside the component (spec:AST-110 FR4) are its files too.
632
- files: [
633
- ...copiedFiles.map(f => f),
634
- ...shared.map(m => path.relative(outputDir, m.dest).split(path.sep).join('/')),
635
- ],
636
- usesStyleX,
515
+ files: outputs.map(o => toPosix(path.relative(outputDir, o.dest))),
516
+ usesStyleX: outputs.some(o => o.content.includes('@stylexjs/stylex')),
517
+ dependencies: plan.dependencies,
637
518
  };
638
519
  if (feedback) data.feedback = feedback;
639
520
  return {type: 'swizzle.copy', package: data.package, data};
@@ -15,5 +15,3 @@ export function swizzle(component?: string, options?: {
15
15
  list?: boolean;
16
16
  overwrite?: boolean;
17
17
  }): Promise<import("./swizzle.type.mjs").SwizzleListResponse | import("./swizzle.type.mjs").SwizzleCopyResponse>;
18
- export { rewriteImports };
19
- import { rewriteImports } from './copy/copy.mjs';
@@ -17,8 +17,12 @@ export const doc = {
17
17
  description:
18
18
  "Ejects a component's source into the consumer project for deep customization. " +
19
19
  'It copies from the locally resolved @astryxdesign/core (or the owning integration) ' +
20
- 'package source, rewriting imports that escape the component directory to the owner ' +
21
- "package's subpaths and flagging whether any copied file uses StyleX. With no name " +
20
+ 'package source. Every import in the copy names a file the copy holds or a path ' +
21
+ 'the owner package exports: names the package exports are imported from its ' +
22
+ 'public paths, and every internal module the component needs is ' +
23
+ "copied beside it in the package's folder layout. Tests, stories and docs are not " +
24
+ 'copied. The receipt flags whether any copied file uses StyleX and lists each npm ' +
25
+ 'package the copy imports with the range the owner package declares. With no name ' +
22
26
  '(or list) it returns the swizzlable component names instead. An integration ' +
23
27
  'component that replaces a Core component is what that Core name copies; ' +
24
28
  "options.package '@astryxdesign/core' copies the original. The list names Core " +
@@ -63,7 +67,8 @@ export const doc = {
63
67
  {
64
68
  name: 'options.overwrite',
65
69
  type: 'boolean',
66
- description: 'Overwrite existing files instead of erroring.',
70
+ description:
71
+ "Replace the component's existing files instead of erroring. A helper an earlier swizzle wrote is never replaced by a copy that carries it: a different one stays, and the new copy writes its own under a version-suffixed name. Swizzling the component whose folder holds that helper counts it as one of that component's own files. A changed private attribute owner module, or a style module it imports, throws ERR_FILE_EXISTS without overwrite and is replaced with it.",
67
72
  default: 'false',
68
73
  },
69
74
  ],
@@ -76,7 +81,7 @@ export const doc = {
76
81
  {
77
82
  type: 'swizzle.copy',
78
83
  description:
79
- 'A receipt after copying the component into the project: the component name, owning package, output directory, files-copied count, the copied file names (relative to the component folder, including the private attribute owner modules and their unexported style modules copied beside it, e.g. `../privateAttributes/press.stylex.ts`), whether any file uses StyleX, and, when the owner has an issues URL, feedback ({issuesUrl, ghCommand?}): where to report the gap that led to swizzling.',
84
+ 'A receipt after copying the component into the project: the component name, owning package, output directory, files-copied count, the copied file names (relative to the component folder; the internal modules copied beside it start with `../`, e.g. `../InputGroup/groupStyles.ts`), whether any file uses StyleX, and, when the owner has an issues URL, feedback ({issuesUrl, ghCommand?}): where to report the gap that led to swizzling.',
80
85
  },
81
86
  ],
82
87
  throws: [