@astryxdesign/cli 0.7.2-canary.caec7ca → 0.7.2-canary.cbb7fa3
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/_adapter.d.mts +41 -1
- package/api/component/_adapter.mjs +93 -7
- package/api/component/component.mjs +22 -5
- package/api/component/component.test.mjs +80 -0
- package/api/component/integration-export-name.test.mjs +644 -0
- package/api/component/list/list.mjs +42 -4
- package/api/gap-report/gap-report.mjs +67 -4
- package/api/integration/add-contribution.component-names.test.mjs +66 -0
- package/api/integration/add-contribution.mjs +7 -3
- package/api/integration/pack-check.mjs +61 -107
- package/api/integration/pack-check.test.mjs +359 -0
- package/api/search/search.d.mts +9 -0
- package/api/search/search.mjs +108 -16
- package/api/search/search.test.mjs +36 -0
- package/api/swizzle/copy/copy.mjs +28 -14
- 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/tree/add-a-component.doc.mjs +10 -0
- package/assets/docs/tree/checks.doc.mjs +1 -1
- package/assets/docs/tree/component-family.doc.mjs +5 -1
- package/assets/docs/tree/component-imports.doc.mjs +8 -0
- package/assets/docs/tree/single-component.doc.mjs +1 -1
- package/assets/docs/tree/troubleshooting.doc.mjs +9 -1
- package/assets/docs/tree/versioning.doc.mjs +1 -0
- package/assets/docs/working-with-ai.doc.mjs +43 -4
- package/authoring/doctypes/component/component.doc.mjs +2 -2
- package/authoring/doctypes/component/type.ts +6 -3
- 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 +172 -10
- package/foundation/discovery/component-discovery.export-names.test.mjs +760 -0
- package/foundation/discovery/component-discovery.mjs +863 -55
- package/foundation/discovery/component-discovery.test.mjs +96 -0
- package/foundation/discovery/component-replacement.mjs +18 -0
- package/foundation/discovery/component-replacement.test.mjs +40 -0
- package/foundation/discovery/module-exports.d.mts +27 -0
- package/foundation/discovery/module-exports.mjs +356 -31
- package/foundation/discovery/module-exports.test.mjs +172 -0
- package/foundation/doc-compiler/doc-loads.test.mjs +4 -0
- package/foundation/doc-compiler/inputs.mjs +23 -0
- package/foundation/doc-compiler/inputs.test.mjs +0 -1
- package/foundation/integrations/contribution-inventory.mjs +6 -1
- package/foundation/xle/registry.mjs +4 -0
- package/foundation/xle/registry.test.mjs +28 -0
- package/package.json +9 -9
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
149
|
-
if (!
|
|
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
|
-
//
|
|
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
|
-
|
|
243
|
+
copyName &&
|
|
244
|
+
(!pkg || (pkg === replacement.package && copyName !== dirName))
|
|
235
245
|
) {
|
|
236
|
-
return swizzleCopy(
|
|
246
|
+
return swizzleCopy(copyName, {
|
|
237
247
|
...options,
|
|
238
248
|
package: replacement.package,
|
|
239
249
|
});
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
const
|
|
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(
|
|
@@ -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',
|
package/assets/codemods/transforms/next/__tests__/migrate-list-edge-compensation-to-edge.test.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
+
];
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Codemod: migrate List edgeCompensation to edge
|
|
5
|
+
*
|
|
6
|
+
* `edgeCompensation="inline"` on an imported `List` becomes `edge="outset"`,
|
|
7
|
+
* the value it already resolves to. List resolves
|
|
8
|
+
* `edge ?? (edgeCompensation === 'inline' ? 'outset' : 'padded')`, so `edge`
|
|
9
|
+
* wins only when it has a value. The deprecated prop is removed beside an
|
|
10
|
+
* `edge` string that no later spread can override; beside a computed `edge`,
|
|
11
|
+
* which may be undefined at runtime, it gets a TODO instead.
|
|
12
|
+
*
|
|
13
|
+
* Built on the shared `transformProp` helper, so it does not follow
|
|
14
|
+
* variables, helpers, wrappers, or types. Where a List writes
|
|
15
|
+
* `edgeCompensation` but the codemod cannot prove the result, it keeps the
|
|
16
|
+
* old prop and leaves a TODO instead of guessing: on a dynamic value, and
|
|
17
|
+
* wherever the List also spreads props. A spread before the old prop may
|
|
18
|
+
* carry an `edge` that wins today and that a new `edge` would override; one
|
|
19
|
+
* after it may override either prop. A spread onto a List that never writes
|
|
20
|
+
* `edgeCompensation` is not evidence of use, so it stays unchanged
|
|
21
|
+
* (`spec:AST-040` FR1): the deprecated prop keeps working through it, and
|
|
22
|
+
* its `@deprecated` type reports a typed use where the props are written.
|
|
23
|
+
* Comments are the codemod's only report channel (`api.report` is a stub),
|
|
24
|
+
* and each is added once, so a second run changes nothing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {transformProp} from '../../transform-prop.mjs';
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
title: 'Migrate List edgeCompensation to edge',
|
|
31
|
+
description:
|
|
32
|
+
'Renames `edgeCompensation="inline"` to `edge="outset"` on List, and ' +
|
|
33
|
+
'drops `edgeCompensation` beside an `edge` string. Keeps the old prop ' +
|
|
34
|
+
'with a TODO where `edge` is computed or a later spread may override ' +
|
|
35
|
+
'it, on a dynamic value, and where the List also spreads props; the ' +
|
|
36
|
+
'deprecated prop still works there.',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const OLD_NAME = 'edgeCompensation';
|
|
40
|
+
const NEW_NAME = 'edge';
|
|
41
|
+
const CORE_IMPORT_RE = /^@(astryxdesign|xds)\/core(?:\/|$)/;
|
|
42
|
+
|
|
43
|
+
const COMPONENT_PROPS = new Map([['List', new Set([OLD_NAME])]]);
|
|
44
|
+
|
|
45
|
+
const DYNAMIC_COMMENT =
|
|
46
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated. Replace ' +
|
|
47
|
+
'edgeCompensation="inline" with edge="outset" by hand; this value is ' +
|
|
48
|
+
'computed. ';
|
|
49
|
+
const BOTH_SET_COMMENT =
|
|
50
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated. edge is also set ' +
|
|
51
|
+
'here, but it may be undefined, and then edgeCompensation still applies. ' +
|
|
52
|
+
'Make edge default to "outset" where edgeCompensation is "inline", then ' +
|
|
53
|
+
'remove edgeCompensation. ';
|
|
54
|
+
const SPREAD_COMMENT =
|
|
55
|
+
' TODO(astryx upgrade): edgeCompensation is deprecated, but this List ' +
|
|
56
|
+
'also spreads props that may set edge, so it was left unchanged. Replace ' +
|
|
57
|
+
'edgeCompensation="inline" with edge="outset" by hand, after checking ' +
|
|
58
|
+
'which edge the spread passes. ';
|
|
59
|
+
|
|
60
|
+
const TYPE_WRAPPERS = new Set([
|
|
61
|
+
'TSAsExpression',
|
|
62
|
+
'TSSatisfiesExpression',
|
|
63
|
+
'TSTypeAssertion',
|
|
64
|
+
'TSNonNullExpression',
|
|
65
|
+
'TypeCastExpression',
|
|
66
|
+
'ParenthesizedExpression',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** @param {any} node */
|
|
70
|
+
function unwrap(node) {
|
|
71
|
+
let current = node;
|
|
72
|
+
while (current && TYPE_WRAPPERS.has(current.type)) {
|
|
73
|
+
current = current.expression;
|
|
74
|
+
}
|
|
75
|
+
return current;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @param {any} node */
|
|
79
|
+
function isStringLiteral(node) {
|
|
80
|
+
const value = unwrap(node);
|
|
81
|
+
return (
|
|
82
|
+
value != null &&
|
|
83
|
+
(value.type === 'StringLiteral' || value.type === 'Literal') &&
|
|
84
|
+
typeof value.value === 'string'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The value of a JSX attribute: the string of `edge="flush"`, or the
|
|
90
|
+
* expression inside `edge={...}`.
|
|
91
|
+
* @param {any} attribute
|
|
92
|
+
*/
|
|
93
|
+
function attributeValue(attribute) {
|
|
94
|
+
return attribute.value?.type === 'JSXExpressionContainer'
|
|
95
|
+
? attribute.value.expression
|
|
96
|
+
: attribute.value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {any} node */
|
|
100
|
+
function isInlineLiteral(node) {
|
|
101
|
+
const value = unwrap(node);
|
|
102
|
+
return (
|
|
103
|
+
value != null &&
|
|
104
|
+
(value.type === 'StringLiteral' || value.type === 'Literal') &&
|
|
105
|
+
value.value === 'inline'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attach a comment to a node once, so re-running the codemod adds nothing.
|
|
111
|
+
* @param {any} j @param {any} node @param {string} text
|
|
112
|
+
*/
|
|
113
|
+
function addComment(j, node, text) {
|
|
114
|
+
node.comments ??= [];
|
|
115
|
+
if (node.comments.some((/** @type {any} */ c) => c.value === text)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
node.comments.push(j.commentBlock(text, true, false));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {any} attributes */
|
|
123
|
+
function firstSpread(attributes) {
|
|
124
|
+
return attributes.find(
|
|
125
|
+
(/** @type {any} */ attribute) => attribute.type === 'JSXSpreadAttribute',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file
|
|
131
|
+
* @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api
|
|
132
|
+
* @returns {string | null | undefined}
|
|
133
|
+
*/
|
|
134
|
+
export default function transformer(file, api) {
|
|
135
|
+
const j = api.jscodeshift;
|
|
136
|
+
const root = j(file.source);
|
|
137
|
+
let hasChanges = false;
|
|
138
|
+
|
|
139
|
+
transformProp(
|
|
140
|
+
root,
|
|
141
|
+
j,
|
|
142
|
+
{
|
|
143
|
+
matchesImport: source => CORE_IMPORT_RE.test(source),
|
|
144
|
+
components: COMPONENT_PROPS,
|
|
145
|
+
},
|
|
146
|
+
propPath => {
|
|
147
|
+
const opening = propPath.parent?.node;
|
|
148
|
+
const attributes = opening?.attributes ?? [];
|
|
149
|
+
const prop = propPath.node;
|
|
150
|
+
const edgeIndex = attributes.findIndex(
|
|
151
|
+
(/** @type {any} */ attribute) =>
|
|
152
|
+
attribute.type === 'JSXAttribute' &&
|
|
153
|
+
attribute.name?.name === NEW_NAME,
|
|
154
|
+
);
|
|
155
|
+
if (edgeIndex !== -1) {
|
|
156
|
+
// `edge` wins only when it has a value. A string that no later
|
|
157
|
+
// spread can override always does, so the old prop is inert and
|
|
158
|
+
// removing it changes nothing. A computed `edge` may be undefined,
|
|
159
|
+
// and then the old prop still decides.
|
|
160
|
+
const isProven =
|
|
161
|
+
isStringLiteral(attributeValue(attributes[edgeIndex])) &&
|
|
162
|
+
!attributes
|
|
163
|
+
.slice(edgeIndex + 1)
|
|
164
|
+
.some(
|
|
165
|
+
(/** @type {any} */ attribute) =>
|
|
166
|
+
attribute.type === 'JSXSpreadAttribute',
|
|
167
|
+
);
|
|
168
|
+
if (isProven) {
|
|
169
|
+
opening.attributes = attributes.filter(
|
|
170
|
+
(/** @type {any} */ attribute) => attribute !== prop,
|
|
171
|
+
);
|
|
172
|
+
hasChanges = true;
|
|
173
|
+
} else {
|
|
174
|
+
hasChanges = addComment(j, prop, BOTH_SET_COMMENT) || hasChanges;
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// With a spread on the element the rename cannot be proven: a spread
|
|
179
|
+
// before the old prop may carry an `edge` that wins today and that a
|
|
180
|
+
// new `edge` would override, and one after it may override either
|
|
181
|
+
// prop. The old prop keeps working, so it stays, with a TODO.
|
|
182
|
+
if (firstSpread(attributes)) {
|
|
183
|
+
hasChanges = addComment(j, prop, SPREAD_COMMENT) || hasChanges;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (isInlineLiteral(attributeValue(prop))) {
|
|
187
|
+
prop.name.name = NEW_NAME;
|
|
188
|
+
prop.value = j.stringLiteral('outset');
|
|
189
|
+
hasChanges = true;
|
|
190
|
+
} else {
|
|
191
|
+
hasChanges = addComment(j, prop, DYNAMIC_COMMENT) || hasChanges;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!hasChanges) return undefined;
|
|
197
|
+
return root.toSource({quote: 'single'});
|
|
198
|
+
}
|
|
@@ -71,5 +71,15 @@ Declare component root ./components in astryx.integration.mjs.
|
|
|
71
71
|
},
|
|
72
72
|
],
|
|
73
73
|
},
|
|
74
|
+
{
|
|
75
|
+
id: 'document-an-existing-component',
|
|
76
|
+
title: 'Document a component you already have',
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: 'prose',
|
|
80
|
+
text: "To document a component your package already has, write its doc beside the source file and name the doc after that file, such as `acme-gallery.doc.mjs` beside `acme-gallery.tsx`. Set `name` to the export apps import, such as `name: 'AcmeGallery'`, and keep both files inside the components root that `astryx.integration.mjs` declares. The file does not have to be named after the component; {@link generic:component-imports} shows how apps then find it.",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
74
84
|
],
|
|
75
85
|
};
|
|
@@ -105,7 +105,7 @@ export const docs = {
|
|
|
105
105
|
'It runs `npm pack` the way `npm publish` would, including your `prepack` script.',
|
|
106
106
|
'It checks that every contribution file is in the `.tgz` file. A root missing from `files` fails with `Add "templates" to "files" in package.json.`',
|
|
107
107
|
'It lists the components, templates, themes, docs, and codemods the temporary app sees, and compares them with your source.',
|
|
108
|
-
"It resolves each component's `import`, and each template's public import, the way Node does, and checks that the module exports the component, or a default export for a template.",
|
|
108
|
+
"It resolves each component's `import`, and each template's public import, the way Node does, and checks that the module exports the component under the name its doc documents, or a default export for a template. A type-only export does not count.",
|
|
109
109
|
'For each theme, it rebuilds the source, compares the built module and production CSS with the authored outputs, and proves the module, CSS, and optional font CSS exports resolve from the packed package.',
|
|
110
110
|
'It fails a package that needs an `@astryxdesign/cli` peer and lacks it: `>=0.6.6` for a template that sets `keywords`, and `>=0.6.4` for a template that sets `replaces`, a docs section, a placed guide, a doc section with an `id`, or a theme.',
|
|
111
111
|
],
|
|
@@ -95,7 +95,11 @@ export default {
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
type: 'prose',
|
|
98
|
-
text:
|
|
98
|
+
text: "The public module named by `import` must export every component or hook named by a full entry. Apps then list, search and open each component in the family by its own name. A full entry the module does not export is not listed, and `integration verify` does not report it: check the family with `astryx component --list`. When `import` names output your pack lifecycle builds (such as `dist`), the members are listed once that output exists, and `integration verify` compares them as they are listed after the pack builds it.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: 'prose',
|
|
102
|
+
text: "A member's source is the `.tsx` file in the family doc's folder, or below it, that declares it: the one the family's own file or the module `import` names reaches, else the one in that folder that declares the name, as when `import` names built output. A member that only a bundle or a file outside that folder declares is still listed, with no source for `--source` or `swizzle`. A member is not listed when a doc's file or a Core name your package replaces already has its name. A member that shares a name with Core opens with `--package`.",
|
|
99
103
|
},
|
|
100
104
|
],
|
|
101
105
|
},
|