@contentful/experience-design-system-cli 2.11.4-dev-build-86a5f6e.0 → 2.11.4-dev-build-7abd382.0
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/dist/package.json +1 -1
- package/dist/src/analyze/command.js +10 -1
- package/dist/src/analyze/extract/pipeline.d.ts +2 -2
- package/dist/src/analyze/extract/pipeline.js +2 -2
- package/dist/src/analyze/extract/svelte.d.ts +2 -2
- package/dist/src/analyze/extract/svelte.js +530 -18
- package/dist/src/types.d.ts +16 -1
- package/package.json +2 -2
package/dist/package.json
CHANGED
|
@@ -99,7 +99,16 @@ export function registerAnalyzeCommand(program) {
|
|
|
99
99
|
.description('Extract component definitions from a project')
|
|
100
100
|
.requiredOption('--project <path>', 'Path to the project root')
|
|
101
101
|
.option('--dir <path>', 'Path to the component source directory relative to the project root')
|
|
102
|
+
.option('--resolve-unreachable <mode>', "Retry pass for unresolved Svelte Props types: 'auto' (default), 'always', or 'never'", 'auto')
|
|
102
103
|
.action(async (opts) => {
|
|
104
|
+
const resolveUnreachable = (() => {
|
|
105
|
+
const v = opts.resolveUnreachable ?? 'auto';
|
|
106
|
+
if (v !== 'auto' && v !== 'always' && v !== 'never') {
|
|
107
|
+
process.stderr.write(`Error: --resolve-unreachable must be one of 'auto', 'always', 'never' (got '${v}')\n`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
})();
|
|
103
112
|
const projectRoot = resolve(opts.project);
|
|
104
113
|
const outDir = join(projectRoot, '.contentful');
|
|
105
114
|
let sourceDirectory;
|
|
@@ -123,7 +132,7 @@ export function registerAnalyzeCommand(program) {
|
|
|
123
132
|
if (!process.stdout.isTTY) {
|
|
124
133
|
process.stderr.write(`progress=extract:${filesProcessed}/${sourceFiles.length}:${componentsFound}\n`);
|
|
125
134
|
}
|
|
126
|
-
});
|
|
135
|
+
}, { resolveUnreachable, projectRoot });
|
|
127
136
|
await mkdir(outDir, { recursive: true });
|
|
128
137
|
const db = openPipelineDb();
|
|
129
138
|
const { sessionId } = getOrCreateSession(db, undefined, undefined, {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { ComponentExtractionResult } from '../../types.js';
|
|
1
|
+
import type { ComponentExtractionResult, ExtractorOptions } from '../../types.js';
|
|
2
2
|
export type ExtractProgress = {
|
|
3
3
|
filesProcessed: number;
|
|
4
4
|
componentsFound: number;
|
|
5
5
|
};
|
|
6
|
-
export declare function extractComponents(filePaths: string[], onProgress?: (progress: ExtractProgress) => void): Promise<ComponentExtractionResult>;
|
|
6
|
+
export declare function extractComponents(filePaths: string[], onProgress?: (progress: ExtractProgress) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
|
|
@@ -224,7 +224,7 @@ function choosePreferredComponent(existing, candidate) {
|
|
|
224
224
|
reason: `kept ${existing.source} over ${candidate.source} by stable first-seen order`,
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
|
-
export async function extractComponents(filePaths, onProgress) {
|
|
227
|
+
export async function extractComponents(filePaths, onProgress, opts) {
|
|
228
228
|
const filesByExtractor = new Map();
|
|
229
229
|
for (const extractor of extractors) {
|
|
230
230
|
filesByExtractor.set(extractor, []);
|
|
@@ -254,7 +254,7 @@ export async function extractComponents(filePaths, onProgress) {
|
|
|
254
254
|
perExtractorFiles.set(extractor, p.filesProcessed);
|
|
255
255
|
perExtractorComponents.set(extractor, p.componentsFound);
|
|
256
256
|
onProgress?.({ filesProcessed: totalFilesProcessed, componentsFound: totalComponentsFound });
|
|
257
|
-
});
|
|
257
|
+
}, opts);
|
|
258
258
|
return result;
|
|
259
259
|
}));
|
|
260
260
|
const allWarnings = [];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ComponentExtractionResult } from '../../types.js';
|
|
1
|
+
import type { ComponentExtractionResult, ExtractorOptions } from '../../types.js';
|
|
2
2
|
export declare function extractSvelteComponents(filePaths: string[], onProgress?: (p: {
|
|
3
3
|
filesProcessed: number;
|
|
4
4
|
componentsFound: number;
|
|
5
|
-
}) => void): Promise<ComponentExtractionResult>;
|
|
5
|
+
}) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { basename, dirname, resolve, join } from 'node:path';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { existsSync, statSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import { parse as parseSvelte } from 'svelte/compiler';
|
|
6
7
|
import { Project, Node, ScriptTarget, ModuleKind, ts } from 'ts-morph';
|
|
7
8
|
import { computeExtractionScore, deriveNeedsReview } from './scoring.js';
|
|
8
9
|
const SVELTE_EXTRACT_CONCURRENCY = Number(process.env['EDS_EXTRACT_CONCURRENCY'] ?? 0) || os.cpus().length;
|
|
9
|
-
export async function extractSvelteComponents(filePaths, onProgress) {
|
|
10
|
+
export async function extractSvelteComponents(filePaths, onProgress, opts) {
|
|
10
11
|
const svelteFiles = filePaths.filter((f) => f.endsWith('.svelte'));
|
|
11
12
|
if (svelteFiles.length === 0)
|
|
12
13
|
return { components: [], warnings: [] };
|
|
13
14
|
const warnings = [];
|
|
14
15
|
const components = [];
|
|
16
|
+
const retryContexts = new Map();
|
|
15
17
|
let filesProcessed = 0;
|
|
16
18
|
let componentsFound = 0;
|
|
17
19
|
const queue = [...svelteFiles];
|
|
@@ -22,12 +24,14 @@ export async function extractSvelteComponents(filePaths, onProgress) {
|
|
|
22
24
|
break;
|
|
23
25
|
try {
|
|
24
26
|
const source = await readFile(filePath, 'utf-8');
|
|
25
|
-
const { component, warnings: fileWarnings } = await extractFromSvelteFile(filePath, source);
|
|
27
|
+
const { component, warnings: fileWarnings, retryContext } = await extractFromSvelteFile(filePath, source);
|
|
26
28
|
warnings.push(...fileWarnings);
|
|
27
29
|
if (component) {
|
|
28
30
|
components.push(component);
|
|
29
31
|
componentsFound++;
|
|
30
32
|
}
|
|
33
|
+
if (retryContext)
|
|
34
|
+
retryContexts.set(filePath, retryContext);
|
|
31
35
|
}
|
|
32
36
|
catch (e) {
|
|
33
37
|
warnings.push(`${getSvelteComponentName(filePath)}: failed to extract from ${filePath} — ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -37,11 +41,443 @@ export async function extractSvelteComponents(filePaths, onProgress) {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
await Promise.all(Array.from({ length: Math.min(SVELTE_EXTRACT_CONCURRENCY, svelteFiles.length) }, worker));
|
|
44
|
+
const finalWarnings = await maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts);
|
|
40
45
|
return {
|
|
41
46
|
components: components.sort((a, b) => a.name.localeCompare(b.name)),
|
|
42
|
-
warnings,
|
|
47
|
+
warnings: collapseUnresolvedTypeWarnings(finalWarnings),
|
|
43
48
|
};
|
|
44
49
|
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Resolve-unreachable retry pass (Approach B)
|
|
52
|
+
//
|
|
53
|
+
// After the normal pass, components flagged `props-type-unresolved` get a
|
|
54
|
+
// second look:
|
|
55
|
+
//
|
|
56
|
+
// Step 1 — tsconfig pass: walk up from projectRoot to the nearest
|
|
57
|
+
// tsconfig.json and build ONE shared ts-morph Project loaded with that
|
|
58
|
+
// tsconfig. Re-run resolution per-component. Recovers cases where the type
|
|
59
|
+
// resolver needed path aliases / baseUrl / a real TS program.
|
|
60
|
+
//
|
|
61
|
+
// Step 2 — node_modules pass: for components that still won't resolve,
|
|
62
|
+
// locate the imported package via Node's resolver, find its .d.ts entry,
|
|
63
|
+
// add it to the shared Project, and retry. Recovers cross-package extends
|
|
64
|
+
// like skeleton-svelte's `extends Omit<ZagProps, 'id'>` from @zag-js/*.
|
|
65
|
+
//
|
|
66
|
+
// Auto mode triggers only when ≥20% of svelte components in the run share
|
|
67
|
+
// the unresolved-type pattern, so the cost of loading a full TS program is
|
|
68
|
+
// paid only when it's likely to help.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
async function maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts) {
|
|
71
|
+
const mode = opts?.resolveUnreachable ?? 'auto';
|
|
72
|
+
if (mode === 'never')
|
|
73
|
+
return warnings;
|
|
74
|
+
const isUnresolved = (c) => (c.reviewReasons ?? []).includes('props-type-unresolved') && !!retryContexts.get(c.source);
|
|
75
|
+
const unresolvedCount = components.filter(isUnresolved).length;
|
|
76
|
+
if (unresolvedCount === 0)
|
|
77
|
+
return warnings;
|
|
78
|
+
if (mode === 'auto') {
|
|
79
|
+
// Threshold: ≥20% of svelte components in the run.
|
|
80
|
+
const ratio = unresolvedCount / components.length;
|
|
81
|
+
if (ratio < 0.2)
|
|
82
|
+
return warnings;
|
|
83
|
+
}
|
|
84
|
+
// Step 1 — tsconfig pass.
|
|
85
|
+
const projectRoot = opts?.projectRoot;
|
|
86
|
+
let project = null;
|
|
87
|
+
let tsconfigPath = null;
|
|
88
|
+
if (projectRoot) {
|
|
89
|
+
tsconfigPath = findNearestTsconfig(projectRoot);
|
|
90
|
+
}
|
|
91
|
+
if (tsconfigPath) {
|
|
92
|
+
try {
|
|
93
|
+
project = new Project({
|
|
94
|
+
tsConfigFilePath: tsconfigPath,
|
|
95
|
+
skipAddingFilesFromTsConfig: false,
|
|
96
|
+
compilerOptions: {
|
|
97
|
+
// Allow declaration-only resolution and JS sources.
|
|
98
|
+
allowJs: true,
|
|
99
|
+
jsx: ts.JsxEmit.Preserve,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
project = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let recoveredViaTsconfig = 0;
|
|
108
|
+
if (project) {
|
|
109
|
+
for (const component of components) {
|
|
110
|
+
if (!isUnresolved(component))
|
|
111
|
+
continue;
|
|
112
|
+
const ctx = retryContexts.get(component.source);
|
|
113
|
+
if (!ctx)
|
|
114
|
+
continue;
|
|
115
|
+
const recovered = await retryComponentWithProject(component, ctx, project, warnings);
|
|
116
|
+
if (recovered)
|
|
117
|
+
recoveredViaTsconfig++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Step 2 — node_modules pass for whatever's still unresolved.
|
|
121
|
+
let recoveredViaNodeModules = 0;
|
|
122
|
+
const stillUnresolved = components.filter(isUnresolved);
|
|
123
|
+
if (stillUnresolved.length > 0) {
|
|
124
|
+
if (!project) {
|
|
125
|
+
// No tsconfig available — fall back to a lightweight project so we still
|
|
126
|
+
// get the node_modules pass. Force Node module resolution so that
|
|
127
|
+
// bare-specifier imports (e.g. `fake-svelte-pkg`) inside the synthetic
|
|
128
|
+
// file find their .d.ts entry on disk.
|
|
129
|
+
project = new Project({
|
|
130
|
+
compilerOptions: {
|
|
131
|
+
strict: false,
|
|
132
|
+
target: ScriptTarget.ESNext,
|
|
133
|
+
module: ModuleKind.ESNext,
|
|
134
|
+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
|
|
135
|
+
allowJs: true,
|
|
136
|
+
jsx: ts.JsxEmit.Preserve,
|
|
137
|
+
},
|
|
138
|
+
useInMemoryFileSystem: false,
|
|
139
|
+
skipAddingFilesFromTsConfig: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
for (const component of stillUnresolved) {
|
|
143
|
+
const ctx = retryContexts.get(component.source);
|
|
144
|
+
if (!ctx)
|
|
145
|
+
continue;
|
|
146
|
+
// Locate the imported types referenced by the user's Props annotation
|
|
147
|
+
// and add their .d.ts files to the shared project.
|
|
148
|
+
const added = addImportedDeclarationsToProject(project, ctx);
|
|
149
|
+
if (added === 0)
|
|
150
|
+
continue;
|
|
151
|
+
const recovered = await retryComponentWithProject(component, ctx, project, warnings);
|
|
152
|
+
if (recovered)
|
|
153
|
+
recoveredViaNodeModules++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Only surface the informational summary when the retry actually had
|
|
157
|
+
// something to work with — i.e. a tsconfig was loaded OR we managed to
|
|
158
|
+
// recover at least one component via node_modules. Otherwise we'd be
|
|
159
|
+
// adding a top-level warning that says "we did nothing", which clutters
|
|
160
|
+
// the output (and breaks the per-component-prefix convention the TUI
|
|
161
|
+
// relies on for grouping).
|
|
162
|
+
const didMeaningfulWork = !!tsconfigPath || recoveredViaNodeModules > 0;
|
|
163
|
+
if (didMeaningfulWork) {
|
|
164
|
+
const remaining = components.filter(isUnresolved).length;
|
|
165
|
+
const parts = [];
|
|
166
|
+
if (tsconfigPath) {
|
|
167
|
+
parts.push(`tsconfig at ${tsconfigPath} recovered ${recoveredViaTsconfig} component(s)`);
|
|
168
|
+
}
|
|
169
|
+
parts.push(`node_modules pass recovered ${recoveredViaNodeModules} more`);
|
|
170
|
+
parts.push(`${remaining} component(s) remain unresolved`);
|
|
171
|
+
warnings.push(`Unresolved-type retry pass (mode=${mode}): ${parts.join('; ')}.`);
|
|
172
|
+
}
|
|
173
|
+
return warnings;
|
|
174
|
+
}
|
|
175
|
+
function findNearestTsconfig(startDir) {
|
|
176
|
+
let dir = startDir;
|
|
177
|
+
// Cap at a reasonable depth to avoid scanning into / on weird inputs.
|
|
178
|
+
for (let i = 0; i < 16; i++) {
|
|
179
|
+
const candidate = join(dir, 'tsconfig.json');
|
|
180
|
+
if (existsSync(candidate))
|
|
181
|
+
return candidate;
|
|
182
|
+
const parent = dirname(dir);
|
|
183
|
+
if (!parent || parent === dir)
|
|
184
|
+
return null;
|
|
185
|
+
dir = parent;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Re-run resolution for a single component against a shared Project.
|
|
191
|
+
* On success, mutate the component to replace props/slots/reviewReasons and
|
|
192
|
+
* remove the per-component "declared Props type ... resolved to ..." warning.
|
|
193
|
+
*/
|
|
194
|
+
async function retryComponentWithProject(component, ctx, project, warnings) {
|
|
195
|
+
const snippetLocals = mergeSets(collectSnippetImportLocals(ctx.instance), ctx.moduleScript ? collectSnippetImportLocals(ctx.moduleScript) : new Set());
|
|
196
|
+
let members = null;
|
|
197
|
+
try {
|
|
198
|
+
members = await resolveViaTypeChecker(ctx.annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source, snippetLocals, project);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
members = null;
|
|
202
|
+
}
|
|
203
|
+
if (!members || members.length === 0)
|
|
204
|
+
return false;
|
|
205
|
+
// If the only members are Snippet-typed (the partial-heritage signal that
|
|
206
|
+
// tripped the original warning), we haven't actually recovered useful props.
|
|
207
|
+
if (members.every((m) => m.isSnippet))
|
|
208
|
+
return false;
|
|
209
|
+
const { props, snippetSlots } = extractFromTypeMembersOnly(members);
|
|
210
|
+
// Merge any template <slot> entries that survived the original pass — we
|
|
211
|
+
// can't easily re-derive those without re-parsing the fragment, but the
|
|
212
|
+
// existing component already has them.
|
|
213
|
+
const templateSlots = component.slots.filter((s) => !snippetSlots.some((ss) => ss.name === s.name));
|
|
214
|
+
const finalSlots = mergeSlots(snippetSlots, templateSlots).slots;
|
|
215
|
+
// Defensive dedupe: if a name somehow ended up in both buckets (e.g. when ts-morph
|
|
216
|
+
// returned `any` for a Snippet member and the Snippet detector failed downstream),
|
|
217
|
+
// the slot wins — slot semantics aren't recoverable from a duplicated prop.
|
|
218
|
+
const slotNames = new Set(finalSlots.map((s) => s.name));
|
|
219
|
+
component.props = props.filter((p) => !slotNames.has(p.name));
|
|
220
|
+
component.slots = finalSlots;
|
|
221
|
+
// Drop props-type-unresolved from reasons; recompute confidence.
|
|
222
|
+
const remainingReasons = (component.reviewReasons ?? []).filter((r) => r !== 'props-type-unresolved');
|
|
223
|
+
const score = computeExtractionScore(component, {
|
|
224
|
+
additionalIssueCount: remainingReasons.length,
|
|
225
|
+
additionalReasons: remainingReasons,
|
|
226
|
+
});
|
|
227
|
+
component.extractionConfidence = score.confidence;
|
|
228
|
+
component.reviewReasons = score.reasons;
|
|
229
|
+
component.needsReview = deriveNeedsReview(score.confidence);
|
|
230
|
+
// Remove the per-component unresolved-type warning so it doesn't muddy the
|
|
231
|
+
// summary line / TUI grouping after recovery.
|
|
232
|
+
const componentName = component.name;
|
|
233
|
+
const idx = warnings.findIndex((w) => w.startsWith(`${componentName}: declared Props type `) && /resolved to /.test(w));
|
|
234
|
+
if (idx >= 0)
|
|
235
|
+
warnings.splice(idx, 1);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Walk every type reference inside the user's Props annotation, find the
|
|
240
|
+
* matching ImportDeclaration in instance/module scripts, resolve the
|
|
241
|
+
* specifier via Node's resolver, locate sibling .d.ts files, and add them to
|
|
242
|
+
* the shared Project. Returns the number of newly-added files.
|
|
243
|
+
*/
|
|
244
|
+
function addImportedDeclarationsToProject(project, ctx) {
|
|
245
|
+
// Start with names referenced directly in the annotation (e.g. `Props`).
|
|
246
|
+
const importedNames = collectReferencedTypeNames(ctx.annotation);
|
|
247
|
+
// Also pull in names referenced from any LOCAL type/interface declaration the
|
|
248
|
+
// annotation points at — that's where heritage clauses (`extends FakeProps`)
|
|
249
|
+
// and intersections live, and they're the ones that name the imported type
|
|
250
|
+
// we actually need to add to the project.
|
|
251
|
+
for (const name of [...importedNames]) {
|
|
252
|
+
const localDecl = findLocalTypeDeclaration(ctx.instance, name, ctx.moduleScript) ??
|
|
253
|
+
(ctx.moduleScript ? findLocalTypeDeclaration(ctx.moduleScript, name, ctx.instance) : null);
|
|
254
|
+
if (localDecl) {
|
|
255
|
+
for (const referenced of collectReferencedTypeNames(localDecl))
|
|
256
|
+
importedNames.add(referenced);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (importedNames.size === 0)
|
|
260
|
+
return 0;
|
|
261
|
+
const imports = collectImportSpecifiersForNames(ctx.instance, importedNames);
|
|
262
|
+
if (ctx.moduleScript) {
|
|
263
|
+
for (const [k, v] of collectImportSpecifiersForNames(ctx.moduleScript, importedNames))
|
|
264
|
+
imports.set(k, v);
|
|
265
|
+
}
|
|
266
|
+
if (imports.size === 0)
|
|
267
|
+
return 0;
|
|
268
|
+
const req = createRequire(ctx.filePath);
|
|
269
|
+
let added = 0;
|
|
270
|
+
for (const specifier of imports.values()) {
|
|
271
|
+
if (specifier.startsWith('.'))
|
|
272
|
+
continue; // already attempted by relative resolver path
|
|
273
|
+
const dtsPath = locateDtsForSpecifier(req, specifier, ctx.filePath);
|
|
274
|
+
if (!dtsPath)
|
|
275
|
+
continue;
|
|
276
|
+
try {
|
|
277
|
+
const sf = project.addSourceFileAtPathIfExists(dtsPath);
|
|
278
|
+
if (sf)
|
|
279
|
+
added++;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Ignore — best-effort.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return added;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Walk a TS type-annotation AST (or interface declaration) and gather every
|
|
289
|
+
* referenced type name. Handles:
|
|
290
|
+
* - TSTypeReference (`Foo`, `Foo<Bar>`)
|
|
291
|
+
* - TSExpressionWithTypeArguments (the `extends Foo` form on interfaces)
|
|
292
|
+
* That second case is critical: it's how `interface Props extends FakeProps {}`
|
|
293
|
+
* surfaces the imported `FakeProps` name we need to add to the project.
|
|
294
|
+
*/
|
|
295
|
+
function collectReferencedTypeNames(node) {
|
|
296
|
+
const names = new Set();
|
|
297
|
+
walk(node);
|
|
298
|
+
return names;
|
|
299
|
+
function walk(n) {
|
|
300
|
+
if (!n || typeof n !== 'object')
|
|
301
|
+
return;
|
|
302
|
+
if (n.type === 'TSTypeReference') {
|
|
303
|
+
const tn = n['typeName']?.['name'];
|
|
304
|
+
if (tn)
|
|
305
|
+
names.add(tn);
|
|
306
|
+
}
|
|
307
|
+
if (n.type === 'TSExpressionWithTypeArguments') {
|
|
308
|
+
const exprName = n['expression']?.['name'];
|
|
309
|
+
if (exprName)
|
|
310
|
+
names.add(exprName);
|
|
311
|
+
}
|
|
312
|
+
for (const value of Object.values(n)) {
|
|
313
|
+
if (Array.isArray(value)) {
|
|
314
|
+
for (const v of value) {
|
|
315
|
+
if (v && typeof v === 'object')
|
|
316
|
+
walk(v);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else if (value && typeof value === 'object' && value.type) {
|
|
320
|
+
walk(value);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* For each named identifier in `names`, find the import declaration that
|
|
327
|
+
* brought it into scope and return a map of `localName -> moduleSpecifier`.
|
|
328
|
+
*/
|
|
329
|
+
function collectImportSpecifiersForNames(script, names) {
|
|
330
|
+
const out = new Map();
|
|
331
|
+
const body = script['content']?.['body'];
|
|
332
|
+
if (!body)
|
|
333
|
+
return out;
|
|
334
|
+
for (const stmt of body) {
|
|
335
|
+
if (stmt.type !== 'ImportDeclaration')
|
|
336
|
+
continue;
|
|
337
|
+
const specifierValue = stmt['source']?.['value'];
|
|
338
|
+
if (!specifierValue)
|
|
339
|
+
continue;
|
|
340
|
+
const specifiers = stmt['specifiers'] ?? [];
|
|
341
|
+
for (const spec of specifiers) {
|
|
342
|
+
if (spec.type !== 'ImportSpecifier' && spec.type !== 'ImportDefaultSpecifier')
|
|
343
|
+
continue;
|
|
344
|
+
const localName = spec['local']?.['name'];
|
|
345
|
+
if (localName && names.has(localName))
|
|
346
|
+
out.set(localName, specifierValue);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Locate a `.d.ts` file for `specifier` resolved relative to `parentFile`.
|
|
353
|
+
* Tries a few strategies in priority order:
|
|
354
|
+
* 1. `package.json#types` / `package.json#exports.types`
|
|
355
|
+
* 2. sibling `.d.ts` / `.d.mts` next to the resolved JS entry
|
|
356
|
+
* 3. `index.d.ts` / `index.d.mts` in the package root
|
|
357
|
+
* Returns null if nothing resolves (no node_modules, dynamic import, etc.).
|
|
358
|
+
*/
|
|
359
|
+
function locateDtsForSpecifier(req, specifier, parentFile) {
|
|
360
|
+
let resolvedJs = null;
|
|
361
|
+
try {
|
|
362
|
+
resolvedJs = req.resolve(specifier);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
resolvedJs = null;
|
|
366
|
+
}
|
|
367
|
+
// Find the package root (nearest package.json above the resolved file or above the parentFile).
|
|
368
|
+
const seedDir = resolvedJs ? dirname(resolvedJs) : dirname(parentFile);
|
|
369
|
+
const pkgRoot = findPackageRootForSpecifier(seedDir, specifier);
|
|
370
|
+
if (pkgRoot) {
|
|
371
|
+
const pkgJsonPath = join(pkgRoot, 'package.json');
|
|
372
|
+
if (existsSync(pkgJsonPath)) {
|
|
373
|
+
try {
|
|
374
|
+
const pkgRaw = readFileSync(pkgJsonPath, 'utf-8');
|
|
375
|
+
const pkg = JSON.parse(pkgRaw);
|
|
376
|
+
const typesField = pkg.types ?? pkg.typings;
|
|
377
|
+
if (typeof typesField === 'string') {
|
|
378
|
+
const candidate = resolve(pkgRoot, typesField);
|
|
379
|
+
if (existsSync(candidate))
|
|
380
|
+
return candidate;
|
|
381
|
+
}
|
|
382
|
+
const exportsTypes = extractTypesFromExports(pkg.exports);
|
|
383
|
+
if (exportsTypes) {
|
|
384
|
+
const candidate = resolve(pkgRoot, exportsTypes);
|
|
385
|
+
if (existsSync(candidate))
|
|
386
|
+
return candidate;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Fall through to sibling probing.
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// index.d.ts at the package root.
|
|
394
|
+
for (const entry of ['index.d.ts', 'index.d.mts']) {
|
|
395
|
+
const candidate = join(pkgRoot, entry);
|
|
396
|
+
if (existsSync(candidate))
|
|
397
|
+
return candidate;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Sibling probing next to the resolved JS file.
|
|
401
|
+
if (resolvedJs) {
|
|
402
|
+
const noExt = resolvedJs.replace(/\.(m?js|cjs)$/, '');
|
|
403
|
+
for (const ext of ['.d.ts', '.d.mts']) {
|
|
404
|
+
const candidate = `${noExt}${ext}`;
|
|
405
|
+
if (existsSync(candidate))
|
|
406
|
+
return candidate;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
function findPackageRootForSpecifier(seedDir, specifier) {
|
|
412
|
+
// For scoped (@x/y) and bare specifiers, the package root is the directory
|
|
413
|
+
// whose path ends with the specifier under a node_modules tree. Walk up.
|
|
414
|
+
let dir = seedDir;
|
|
415
|
+
for (let i = 0; i < 32; i++) {
|
|
416
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
417
|
+
// Check this is the package matching the specifier (best-effort: name field).
|
|
418
|
+
try {
|
|
419
|
+
const pkgRaw = readFileSync(join(dir, 'package.json'), 'utf-8');
|
|
420
|
+
const pkg = JSON.parse(pkgRaw);
|
|
421
|
+
// Either exact match or a sub-path import (foo/sub) where pkg.name === 'foo'.
|
|
422
|
+
if (pkg.name === specifier)
|
|
423
|
+
return dir;
|
|
424
|
+
if (pkg.name && specifier.startsWith(`${pkg.name}/`))
|
|
425
|
+
return dir;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Ignore and continue walking.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const parent = dirname(dir);
|
|
432
|
+
if (!parent || parent === dir)
|
|
433
|
+
return null;
|
|
434
|
+
dir = parent;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
function extractTypesFromExports(exportsField) {
|
|
439
|
+
if (!exportsField || typeof exportsField !== 'object')
|
|
440
|
+
return null;
|
|
441
|
+
const exp = exportsField;
|
|
442
|
+
// Look for the "." entry first; fall back to top-level types if shape is conditional.
|
|
443
|
+
const root = (exp['.'] ?? exp);
|
|
444
|
+
if (!root || typeof root !== 'object')
|
|
445
|
+
return null;
|
|
446
|
+
const r = root;
|
|
447
|
+
if (typeof r['types'] === 'string')
|
|
448
|
+
return r['types'];
|
|
449
|
+
// Conditional exports: try import → types, default → types.
|
|
450
|
+
for (const key of ['import', 'default', 'node']) {
|
|
451
|
+
const sub = r[key];
|
|
452
|
+
if (sub && typeof sub === 'object') {
|
|
453
|
+
const t = sub['types'];
|
|
454
|
+
if (typeof t === 'string')
|
|
455
|
+
return t;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* When a large fraction of components emit the same `declared Props type ...
|
|
462
|
+
* resolved to ... properties` warning (typical of headless libraries like
|
|
463
|
+
* skeleton-svelte that extend types from external packages we can't reach),
|
|
464
|
+
* collapse them into a single summary line at the top + the per-component
|
|
465
|
+
* details below. Per-component reviewReasons and needsReview stay intact;
|
|
466
|
+
* this only affects the warnings array's readability.
|
|
467
|
+
*
|
|
468
|
+
* Threshold: 3 or more identical-shape warnings. Below that, the literal
|
|
469
|
+
* per-component lines are clearer than a summary.
|
|
470
|
+
*/
|
|
471
|
+
function collapseUnresolvedTypeWarnings(warnings) {
|
|
472
|
+
const isUnresolvedWarning = (w) => /declared Props type .* resolved to/.test(w);
|
|
473
|
+
const unresolved = warnings.filter(isUnresolvedWarning);
|
|
474
|
+
if (unresolved.length < 3)
|
|
475
|
+
return warnings;
|
|
476
|
+
const summary = `Unresolved component types: ${unresolved.length} components have a declared Props type the parser couldn't fully resolve — ` +
|
|
477
|
+
`most often a cross-package extends pattern (e.g. an interface that extends a type from a node_modules package). ` +
|
|
478
|
+
`See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`;
|
|
479
|
+
return [summary, ...warnings];
|
|
480
|
+
}
|
|
45
481
|
async function extractFromSvelteFile(filePath, source) {
|
|
46
482
|
const warnings = [];
|
|
47
483
|
// Derive the component name up front so warning messages can prefix it for
|
|
@@ -130,7 +566,25 @@ async function extractFromSvelteFile(filePath, source) {
|
|
|
130
566
|
// extractors return — same convention as React, Vue, Astro, Stencil, and
|
|
131
567
|
// web-components. No per-extractor work needed here.
|
|
132
568
|
void propNamesTreatedAsSnippet; // currently informational; reserved for future validation
|
|
133
|
-
|
|
569
|
+
// Capture context for the resolve-unreachable retry pass. Only meaningful
|
|
570
|
+
// when extraction actually got back the props-type-unresolved signal AND
|
|
571
|
+
// we have the annotation node needed to re-run resolution.
|
|
572
|
+
let retryContext;
|
|
573
|
+
if (extractionReasons.includes('props-type-unresolved') && propsCall && instance) {
|
|
574
|
+
const id = propsCall['id'];
|
|
575
|
+
const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
|
|
576
|
+
if (annotation) {
|
|
577
|
+
retryContext = {
|
|
578
|
+
filePath,
|
|
579
|
+
source,
|
|
580
|
+
instance,
|
|
581
|
+
moduleScript,
|
|
582
|
+
annotation,
|
|
583
|
+
componentName: name,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { component, warnings, ...(retryContext ? { retryContext } : {}) };
|
|
134
588
|
}
|
|
135
589
|
// ---------------------------------------------------------------------------
|
|
136
590
|
// Component name
|
|
@@ -366,7 +820,7 @@ function declarationHasHeritage(decl) {
|
|
|
366
820
|
}
|
|
367
821
|
return false;
|
|
368
822
|
}
|
|
369
|
-
async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals) {
|
|
823
|
+
async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals, externalProject) {
|
|
370
824
|
const annotationText = sliceSource(source, annotation);
|
|
371
825
|
if (!annotationText)
|
|
372
826
|
return null;
|
|
@@ -376,17 +830,21 @@ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePat
|
|
|
376
830
|
const moduleText = sliceScriptContent(source, moduleScript);
|
|
377
831
|
const instanceText = sliceScriptContent(source, instance);
|
|
378
832
|
const synthetic = [moduleText, instanceText, `type __SveltePropsT__ = ${annotationText};`].filter(Boolean).join('\n');
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
833
|
+
// Reuse the caller-supplied Project if provided (the resolve-unreachable
|
|
834
|
+
// retry pass shares one Project across all components in the run); otherwise
|
|
835
|
+
// build a one-off project for this single call.
|
|
836
|
+
const project = externalProject ??
|
|
837
|
+
new Project({
|
|
838
|
+
compilerOptions: {
|
|
839
|
+
strict: false,
|
|
840
|
+
target: ScriptTarget.ESNext,
|
|
841
|
+
module: ModuleKind.ESNext,
|
|
842
|
+
allowJs: true,
|
|
843
|
+
jsx: ts.JsxEmit.Preserve,
|
|
844
|
+
},
|
|
845
|
+
useInMemoryFileSystem: false,
|
|
846
|
+
skipAddingFilesFromTsConfig: true,
|
|
847
|
+
});
|
|
390
848
|
// Place the synthetic file alongside the .svelte so relative imports resolve.
|
|
391
849
|
const syntheticPath = `${filePath}.__svelte-props__.ts`;
|
|
392
850
|
let sf;
|
|
@@ -419,7 +877,18 @@ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePat
|
|
|
419
877
|
}
|
|
420
878
|
const allowed = extractAllowedValuesFromType(propType);
|
|
421
879
|
const description = readJsDocFromDeclaration(declaration);
|
|
422
|
-
|
|
880
|
+
// Snippet detection — try three signals in order of reliability:
|
|
881
|
+
// 1. The author's declared type-node text on the property's source declaration.
|
|
882
|
+
// This survives even when ts-morph fails to fully resolve a generic
|
|
883
|
+
// instantiation and falls back to `any`/`unknown` (intermittent under
|
|
884
|
+
// partial type-checker state).
|
|
885
|
+
// 2. The resolved type's alias-symbol chain — works when ts-morph DID fully
|
|
886
|
+
// resolve the type (`Snippet<[T]>` expanded to its call signature).
|
|
887
|
+
// 3. Text-match on the rendered type. Last-resort fallback.
|
|
888
|
+
const declaredTypeText = readDeclaredTypeNodeText(declaration);
|
|
889
|
+
const isSnippet = (declaredTypeText !== null && isSnippetTypeText(declaredTypeText, snippetLocals)) ||
|
|
890
|
+
typeRefersToSnippet(propType) ||
|
|
891
|
+
isSnippetTypeText(typeText, snippetLocals);
|
|
423
892
|
members.push({
|
|
424
893
|
name,
|
|
425
894
|
optional,
|
|
@@ -468,6 +937,20 @@ function readJsDocFromDeclaration(decl) {
|
|
|
468
937
|
}
|
|
469
938
|
return undefined;
|
|
470
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Read the literal text of the property's declared type annotation as the
|
|
942
|
+
* AUTHOR wrote it (e.g. `Snippet<[Attrs]>`), independent of what the TS
|
|
943
|
+
* checker resolved it to. This is critical because ts-morph's checker can
|
|
944
|
+
* intermittently fall back to `any`/`unknown` for cross-package generic
|
|
945
|
+
* instantiations, in which case the symbol-/alias-based detector has nothing
|
|
946
|
+
* to follow. The author's syntactic annotation is stable in either case.
|
|
947
|
+
*/
|
|
948
|
+
function readDeclaredTypeNodeText(decl) {
|
|
949
|
+
if (Node.isPropertySignature(decl)) {
|
|
950
|
+
return decl.getTypeNode()?.getText() ?? null;
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
471
954
|
function isSnippetTypeText(typeText, snippetLocals) {
|
|
472
955
|
// Direct match against the local Snippet name (handles aliasing).
|
|
473
956
|
for (const local of snippetLocals) {
|
|
@@ -477,6 +960,35 @@ function isSnippetTypeText(typeText, snippetLocals) {
|
|
|
477
960
|
// Defensive fallback: TS may surface the canonical `Snippet` from the import.
|
|
478
961
|
return typeText === 'Snippet' || typeText.startsWith('Snippet<');
|
|
479
962
|
}
|
|
963
|
+
function typeRefersToSnippet(propType) {
|
|
964
|
+
const seen = new Set();
|
|
965
|
+
let cursor = propType;
|
|
966
|
+
while (cursor && !seen.has(cursor)) {
|
|
967
|
+
seen.add(cursor);
|
|
968
|
+
const aliasName = cursor.getAliasSymbol()?.getName();
|
|
969
|
+
const symName = cursor.getSymbol()?.getName();
|
|
970
|
+
if (aliasName === 'Snippet' || symName === 'Snippet') {
|
|
971
|
+
const decl = cursor.getAliasSymbol()?.getDeclarations()[0] ?? cursor.getSymbol()?.getDeclarations()[0];
|
|
972
|
+
const file = decl?.getSourceFile().getFilePath() ?? '';
|
|
973
|
+
// Accept Snippet from anywhere named "svelte" in the path; users almost
|
|
974
|
+
// never name an unrelated type "Snippet" in their own code, but the
|
|
975
|
+
// path check guards against the rare false positive.
|
|
976
|
+
if (file.includes('/svelte/') || file.includes('\\svelte\\') || file === '')
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
// Strip optional `| undefined` and recurse into single-element unions
|
|
980
|
+
// (covers `Snippet | undefined` etc.).
|
|
981
|
+
if (cursor.isUnion()) {
|
|
982
|
+
const nonUndef = cursor.getUnionTypes().filter((t) => !t.isUndefined() && !t.isNull());
|
|
983
|
+
if (nonUndef.length === 1) {
|
|
984
|
+
cursor = nonUndef[0];
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
480
992
|
function mergeSets(a, b) {
|
|
481
993
|
const out = new Set(a);
|
|
482
994
|
for (const v of b)
|
package/dist/src/types.d.ts
CHANGED
|
@@ -54,10 +54,25 @@ export type ExtractorProgress = {
|
|
|
54
54
|
filesProcessed: number;
|
|
55
55
|
componentsFound: number;
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Optional extraction-time settings forwarded from the CLI through the
|
|
59
|
+
* pipeline to each extractor. Only the Svelte extractor currently consumes
|
|
60
|
+
* any of these; other extractors ignore the value.
|
|
61
|
+
*/
|
|
62
|
+
export interface ExtractorOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Whether the Svelte extractor should run a retry pass for components whose
|
|
65
|
+
* declared Props type couldn't be resolved on the first pass (cross-package
|
|
66
|
+
* extends, path-alias-only types, etc.). See svelte.ts for the policy.
|
|
67
|
+
*/
|
|
68
|
+
resolveUnreachable?: 'auto' | 'always' | 'never';
|
|
69
|
+
/** Absolute project root — used by the retry pass to locate tsconfig.json and node_modules. */
|
|
70
|
+
projectRoot?: string;
|
|
71
|
+
}
|
|
57
72
|
export interface ComponentExtractor {
|
|
58
73
|
name: string;
|
|
59
74
|
fileFilter: (filePath: string) => boolean;
|
|
60
|
-
extract(filePaths: string[], onProgress?: (p: ExtractorProgress) => void): Promise<ComponentExtractionResult>;
|
|
75
|
+
extract(filePaths: string[], onProgress?: (p: ExtractorProgress) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
|
|
61
76
|
}
|
|
62
77
|
export interface TokenExtractor {
|
|
63
78
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentful/experience-design-system-cli",
|
|
3
|
-
"version": "2.11.4-dev-build-
|
|
3
|
+
"version": "2.11.4-dev-build-7abd382.0",
|
|
4
4
|
"description": "Contentful Experiences design system import CLI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"svelte": "^5.56.4",
|
|
38
38
|
"ts-morph": "^27.0.2",
|
|
39
39
|
"typescript": "^5.9.3",
|
|
40
|
-
"@contentful/experience-design-system-types": "2.11.4-dev-build-
|
|
40
|
+
"@contentful/experience-design-system-types": "2.11.4-dev-build-7abd382.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@tsconfig/node24": "^24.0.3",
|