@contentful/experience-design-system-cli 2.12.4-dev-build-9c819d8.0 → 2.13.1-dev-build-847dead.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 +3 -6
- package/dist/src/analyze/command.js +2 -22
- package/dist/src/analyze/select/command.js +1 -1
- package/dist/src/analyze/select/tui/components/FieldEditor.js +4 -1
- package/dist/src/analyze/select-agent/command.js +1 -1
- package/dist/src/apply/command.d.ts +1 -5
- package/dist/src/apply/command.js +0 -43
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +1 -1
- package/dist/src/import/tui/steps/GenerateReviewStep.js +20 -102
- package/dist/src/import/tui/steps/WizardPreviewStep.d.ts +0 -10
- package/dist/src/import/tui/steps/WizardPreviewStep.js +51 -95
- package/dist/src/import/tui/steps/preview-diff.js +0 -48
- package/dist/src/session/db.d.ts +0 -9
- package/dist/src/session/db.js +0 -46
- package/dist/src/types.d.ts +2 -76
- package/dist/src/types.js +1 -4
- package/package.json +3 -6
- package/dist/src/analyze/cycle-detection.d.ts +0 -68
- package/dist/src/analyze/cycle-detection.js +0 -285
- package/dist/src/analyze/extract/astro.d.ts +0 -5
- package/dist/src/analyze/extract/astro.js +0 -289
- package/dist/src/analyze/extract/non-authorable-filter.d.ts +0 -16
- package/dist/src/analyze/extract/non-authorable-filter.js +0 -60
- package/dist/src/analyze/extract/pipeline.d.ts +0 -6
- package/dist/src/analyze/extract/pipeline.js +0 -314
- package/dist/src/analyze/extract/react.d.ts +0 -2
- package/dist/src/analyze/extract/react.js +0 -2041
- package/dist/src/analyze/extract/scoring.d.ts +0 -12
- package/dist/src/analyze/extract/scoring.js +0 -108
- package/dist/src/analyze/extract/slot-allowed-components.d.ts +0 -8
- package/dist/src/analyze/extract/slot-allowed-components.js +0 -40
- package/dist/src/analyze/extract/slot-detection.d.ts +0 -35
- package/dist/src/analyze/extract/slot-detection.js +0 -101
- package/dist/src/analyze/extract/source-inspection.d.ts +0 -13
- package/dist/src/analyze/extract/source-inspection.js +0 -189
- package/dist/src/analyze/extract/stencil.d.ts +0 -2
- package/dist/src/analyze/extract/stencil.js +0 -296
- package/dist/src/analyze/extract/svelte.d.ts +0 -5
- package/dist/src/analyze/extract/svelte.js +0 -1626
- package/dist/src/analyze/extract/tsx-shared.d.ts +0 -8
- package/dist/src/analyze/extract/tsx-shared.js +0 -263
- package/dist/src/analyze/extract/validate.d.ts +0 -16
- package/dist/src/analyze/extract/validate.js +0 -94
- package/dist/src/analyze/extract/vue-tsx.d.ts +0 -2
- package/dist/src/analyze/extract/vue-tsx.js +0 -498
- package/dist/src/analyze/extract/vue.d.ts +0 -5
- package/dist/src/analyze/extract/vue.js +0 -653
- package/dist/src/analyze/extract/web-components.d.ts +0 -2
- package/dist/src/analyze/extract/web-components.js +0 -876
- package/dist/src/analyze/pre-classify.d.ts +0 -17
- package/dist/src/analyze/pre-classify.js +0 -211
|
@@ -1,1626 +0,0 @@
|
|
|
1
|
-
import { basename, dirname, resolve, join } from 'node:path';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import { parse as parseSvelte } from 'svelte/compiler';
|
|
7
|
-
import { Project, Node, ScriptTarget, ModuleKind, ts } from 'ts-morph';
|
|
8
|
-
import { computeExtractionScore, deriveNeedsReview } from './scoring.js';
|
|
9
|
-
import { extractAllowedComponentsFromTypeText } from './slot-allowed-components.js';
|
|
10
|
-
const SVELTE_EXTRACT_CONCURRENCY = Number(process.env['EDS_EXTRACT_CONCURRENCY'] ?? 0) || os.cpus().length;
|
|
11
|
-
export async function extractSvelteComponents(filePaths, onProgress, opts) {
|
|
12
|
-
const svelteFiles = filePaths.filter((f) => f.endsWith('.svelte'));
|
|
13
|
-
if (svelteFiles.length === 0)
|
|
14
|
-
return { components: [], warnings: [] };
|
|
15
|
-
const warnings = [];
|
|
16
|
-
const components = [];
|
|
17
|
-
const retryContexts = new Map();
|
|
18
|
-
let filesProcessed = 0;
|
|
19
|
-
let componentsFound = 0;
|
|
20
|
-
const queue = [...svelteFiles];
|
|
21
|
-
async function worker() {
|
|
22
|
-
while (queue.length > 0) {
|
|
23
|
-
const filePath = queue.shift();
|
|
24
|
-
if (!filePath)
|
|
25
|
-
break;
|
|
26
|
-
try {
|
|
27
|
-
const source = await readFile(filePath, 'utf-8');
|
|
28
|
-
const { component, warnings: fileWarnings, retryContext } = await extractFromSvelteFile(filePath, source);
|
|
29
|
-
warnings.push(...fileWarnings);
|
|
30
|
-
if (component) {
|
|
31
|
-
components.push(component);
|
|
32
|
-
componentsFound++;
|
|
33
|
-
}
|
|
34
|
-
if (retryContext)
|
|
35
|
-
retryContexts.set(filePath, retryContext);
|
|
36
|
-
}
|
|
37
|
-
catch (e) {
|
|
38
|
-
warnings.push(`${getSvelteComponentName(filePath)}: failed to extract from ${filePath} — ${e instanceof Error ? e.message : String(e)}`);
|
|
39
|
-
}
|
|
40
|
-
filesProcessed++;
|
|
41
|
-
onProgress?.({ filesProcessed, componentsFound });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
await Promise.all(Array.from({ length: Math.min(SVELTE_EXTRACT_CONCURRENCY, svelteFiles.length) }, worker));
|
|
45
|
-
const finalWarnings = await maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts);
|
|
46
|
-
// Post-pass: resolve $allowedComponents for snippet slots whose type text
|
|
47
|
-
// referenced Snippet<[XProps]>. Build a props-type-name → component-name map
|
|
48
|
-
// from the full run first so cross-file references resolve.
|
|
49
|
-
resolveAllowedComponents(components);
|
|
50
|
-
return {
|
|
51
|
-
components: components.sort((a, b) => a.name.localeCompare(b.name)),
|
|
52
|
-
warnings: collapseUnresolvedTypeWarnings(finalWarnings),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
function resolveAllowedComponents(components) {
|
|
56
|
-
const propsToComponent = new Map();
|
|
57
|
-
const componentNames = new Set();
|
|
58
|
-
for (const c of components) {
|
|
59
|
-
componentNames.add(c.name);
|
|
60
|
-
if (c._propsTypeName)
|
|
61
|
-
propsToComponent.set(c._propsTypeName, c.name);
|
|
62
|
-
}
|
|
63
|
-
for (const c of components) {
|
|
64
|
-
for (const slot of c.slots) {
|
|
65
|
-
const raw = slot._rawTypeText;
|
|
66
|
-
if (raw) {
|
|
67
|
-
const found = extractAllowedComponentsFromTypeText(raw, { propsToComponent, componentNames });
|
|
68
|
-
if (found.length > 0)
|
|
69
|
-
slot.allowedComponents = found;
|
|
70
|
-
}
|
|
71
|
-
delete slot._rawTypeText;
|
|
72
|
-
}
|
|
73
|
-
delete c._propsTypeName;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Resolve-unreachable retry pass (Approach B)
|
|
78
|
-
//
|
|
79
|
-
// After the normal pass, components flagged `props-type-unresolved` get a
|
|
80
|
-
// second look:
|
|
81
|
-
//
|
|
82
|
-
// Step 1 — tsconfig pass: walk up from projectRoot to the nearest
|
|
83
|
-
// tsconfig.json and build ONE shared ts-morph Project loaded with that
|
|
84
|
-
// tsconfig. Re-run resolution per-component. Recovers cases where the type
|
|
85
|
-
// resolver needed path aliases / baseUrl / a real TS program.
|
|
86
|
-
//
|
|
87
|
-
// Step 2 — node_modules pass: for components that still won't resolve,
|
|
88
|
-
// locate the imported package via Node's resolver, find its .d.ts entry,
|
|
89
|
-
// add it to the shared Project, and retry. Recovers cross-package extends
|
|
90
|
-
// like skeleton-svelte's `extends Omit<ZagProps, 'id'>` from @zag-js/*.
|
|
91
|
-
//
|
|
92
|
-
// Auto mode triggers only when ≥20% of svelte components in the run share
|
|
93
|
-
// the unresolved-type pattern, so the cost of loading a full TS program is
|
|
94
|
-
// paid only when it's likely to help.
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
async function maybeRunResolveUnreachableRetry(components, warnings, retryContexts, opts) {
|
|
97
|
-
const mode = opts?.resolveUnreachable ?? 'auto';
|
|
98
|
-
if (mode === 'never')
|
|
99
|
-
return warnings;
|
|
100
|
-
const isUnresolved = (c) => (c.reviewReasons ?? []).includes('props-type-unresolved') && !!retryContexts.get(c.source);
|
|
101
|
-
const unresolvedCount = components.filter(isUnresolved).length;
|
|
102
|
-
if (unresolvedCount === 0)
|
|
103
|
-
return warnings;
|
|
104
|
-
if (mode === 'auto') {
|
|
105
|
-
// Threshold: ≥20% of svelte components in the run.
|
|
106
|
-
const ratio = unresolvedCount / components.length;
|
|
107
|
-
if (ratio < 0.2)
|
|
108
|
-
return warnings;
|
|
109
|
-
}
|
|
110
|
-
// Step 1 — tsconfig pass.
|
|
111
|
-
const projectRoot = opts?.projectRoot;
|
|
112
|
-
let project = null;
|
|
113
|
-
let tsconfigPath = null;
|
|
114
|
-
if (projectRoot) {
|
|
115
|
-
tsconfigPath = findNearestTsconfig(projectRoot);
|
|
116
|
-
}
|
|
117
|
-
if (tsconfigPath) {
|
|
118
|
-
try {
|
|
119
|
-
project = new Project({
|
|
120
|
-
tsConfigFilePath: tsconfigPath,
|
|
121
|
-
skipAddingFilesFromTsConfig: false,
|
|
122
|
-
compilerOptions: {
|
|
123
|
-
// Allow declaration-only resolution and JS sources.
|
|
124
|
-
allowJs: true,
|
|
125
|
-
jsx: ts.JsxEmit.Preserve,
|
|
126
|
-
},
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
project = null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
let recoveredViaTsconfig = 0;
|
|
134
|
-
if (project) {
|
|
135
|
-
for (const component of components) {
|
|
136
|
-
if (!isUnresolved(component))
|
|
137
|
-
continue;
|
|
138
|
-
const ctx = retryContexts.get(component.source);
|
|
139
|
-
if (!ctx)
|
|
140
|
-
continue;
|
|
141
|
-
const recovered = await retryComponentWithProject(component, ctx, project, warnings);
|
|
142
|
-
if (recovered)
|
|
143
|
-
recoveredViaTsconfig++;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
// Step 2 — node_modules pass for whatever's still unresolved.
|
|
147
|
-
let recoveredViaNodeModules = 0;
|
|
148
|
-
const stillUnresolved = components.filter(isUnresolved);
|
|
149
|
-
if (stillUnresolved.length > 0) {
|
|
150
|
-
if (!project) {
|
|
151
|
-
// No tsconfig available — fall back to a lightweight project so we still
|
|
152
|
-
// get the node_modules pass. Force Node module resolution so that
|
|
153
|
-
// bare-specifier imports (e.g. `fake-svelte-pkg`) inside the synthetic
|
|
154
|
-
// file find their .d.ts entry on disk.
|
|
155
|
-
project = new Project({
|
|
156
|
-
compilerOptions: {
|
|
157
|
-
strict: false,
|
|
158
|
-
target: ScriptTarget.ESNext,
|
|
159
|
-
module: ModuleKind.ESNext,
|
|
160
|
-
moduleResolution: ts.ModuleResolutionKind.NodeJs,
|
|
161
|
-
allowJs: true,
|
|
162
|
-
jsx: ts.JsxEmit.Preserve,
|
|
163
|
-
},
|
|
164
|
-
useInMemoryFileSystem: false,
|
|
165
|
-
skipAddingFilesFromTsConfig: true,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
for (const component of stillUnresolved) {
|
|
169
|
-
const ctx = retryContexts.get(component.source);
|
|
170
|
-
if (!ctx)
|
|
171
|
-
continue;
|
|
172
|
-
// Locate the imported types referenced by the user's Props annotation
|
|
173
|
-
// and add their .d.ts files to the shared project.
|
|
174
|
-
const added = addImportedDeclarationsToProject(project, ctx);
|
|
175
|
-
if (added === 0)
|
|
176
|
-
continue;
|
|
177
|
-
const recovered = await retryComponentWithProject(component, ctx, project, warnings);
|
|
178
|
-
if (recovered)
|
|
179
|
-
recoveredViaNodeModules++;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
// Only surface the informational summary when the retry actually had
|
|
183
|
-
// something to work with — i.e. a tsconfig was loaded OR we managed to
|
|
184
|
-
// recover at least one component via node_modules. Otherwise we'd be
|
|
185
|
-
// adding a top-level warning that says "we did nothing", which clutters
|
|
186
|
-
// the output (and breaks the per-component-prefix convention the TUI
|
|
187
|
-
// relies on for grouping).
|
|
188
|
-
const didMeaningfulWork = !!tsconfigPath || recoveredViaNodeModules > 0;
|
|
189
|
-
if (didMeaningfulWork) {
|
|
190
|
-
const remaining = components.filter(isUnresolved).length;
|
|
191
|
-
const parts = [];
|
|
192
|
-
if (tsconfigPath) {
|
|
193
|
-
parts.push(`tsconfig at ${tsconfigPath} recovered ${recoveredViaTsconfig} component(s)`);
|
|
194
|
-
}
|
|
195
|
-
parts.push(`node_modules pass recovered ${recoveredViaNodeModules} more`);
|
|
196
|
-
parts.push(`${remaining} component(s) remain unresolved`);
|
|
197
|
-
warnings.push(`Unresolved-type retry pass (mode=${mode}): ${parts.join('; ')}.`);
|
|
198
|
-
}
|
|
199
|
-
return warnings;
|
|
200
|
-
}
|
|
201
|
-
function findNearestTsconfig(startDir) {
|
|
202
|
-
let dir = startDir;
|
|
203
|
-
// Cap at a reasonable depth to avoid scanning into / on weird inputs.
|
|
204
|
-
for (let i = 0; i < 16; i++) {
|
|
205
|
-
const candidate = join(dir, 'tsconfig.json');
|
|
206
|
-
if (existsSync(candidate))
|
|
207
|
-
return candidate;
|
|
208
|
-
const parent = dirname(dir);
|
|
209
|
-
if (!parent || parent === dir)
|
|
210
|
-
return null;
|
|
211
|
-
dir = parent;
|
|
212
|
-
}
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* Re-run resolution for a single component against a shared Project.
|
|
217
|
-
* On success, mutate the component to replace props/slots/reviewReasons and
|
|
218
|
-
* remove the per-component "declared Props type ... resolved to ..." warning.
|
|
219
|
-
*/
|
|
220
|
-
async function retryComponentWithProject(component, ctx, project, warnings) {
|
|
221
|
-
const snippetLocals = mergeSets(collectSnippetImportLocals(ctx.instance), ctx.moduleScript ? collectSnippetImportLocals(ctx.moduleScript) : new Set());
|
|
222
|
-
let members = null;
|
|
223
|
-
try {
|
|
224
|
-
members = await resolveViaTypeChecker(ctx.annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source, snippetLocals, project);
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
members = null;
|
|
228
|
-
}
|
|
229
|
-
if (!members || members.length === 0)
|
|
230
|
-
return false;
|
|
231
|
-
// If the resolver returned a complete member list — even when it's
|
|
232
|
-
// Snippet-only — that's a real answer, not a partial one. Apply it and
|
|
233
|
-
// drop `props-type-unresolved`. Whether the resulting component still
|
|
234
|
-
// needs review will be decided by the standard heuristics
|
|
235
|
-
// (no-props-or-slots, infra-fetch, etc.) downstream.
|
|
236
|
-
const { props, snippetSlots } = extractFromTypeMembersOnly(members);
|
|
237
|
-
// Merge any template <slot> entries that survived the original pass — we
|
|
238
|
-
// can't easily re-derive those without re-parsing the fragment, but the
|
|
239
|
-
// existing component already has them.
|
|
240
|
-
const templateSlots = component.slots.filter((s) => !snippetSlots.some((ss) => ss.name === s.name));
|
|
241
|
-
const finalSlots = mergeSlots(snippetSlots, templateSlots).slots;
|
|
242
|
-
// Defensive dedupe: if a name somehow ended up in both buckets (e.g. when ts-morph
|
|
243
|
-
// returned `any` for a Snippet member and the Snippet detector failed downstream),
|
|
244
|
-
// the slot wins — slot semantics aren't recoverable from a duplicated prop.
|
|
245
|
-
const slotNames = new Set(finalSlots.map((s) => s.name));
|
|
246
|
-
component.props = props.filter((p) => !slotNames.has(p.name));
|
|
247
|
-
component.slots = finalSlots;
|
|
248
|
-
// Drop `props-type-unresolved` from reasons: the type DID resolve. Other
|
|
249
|
-
// heuristics (no-props-or-slots, infra-fetch, etc.) decide whether the
|
|
250
|
-
// recovered component still needs review.
|
|
251
|
-
const remainingReasons = (component.reviewReasons ?? []).filter((r) => r !== 'props-type-unresolved');
|
|
252
|
-
const score = computeExtractionScore(component, {
|
|
253
|
-
additionalIssueCount: remainingReasons.length,
|
|
254
|
-
additionalReasons: remainingReasons,
|
|
255
|
-
});
|
|
256
|
-
component.extractionConfidence = score.confidence;
|
|
257
|
-
component.reviewReasons = score.reasons;
|
|
258
|
-
component.needsReview = deriveNeedsReview(score.confidence);
|
|
259
|
-
// Remove the per-component unresolved-type warning — recovery is complete.
|
|
260
|
-
const componentName = component.name;
|
|
261
|
-
const idx = warnings.findIndex((w) => w.startsWith(`${componentName}: declared Props type `) && /resolved to /.test(w));
|
|
262
|
-
if (idx >= 0)
|
|
263
|
-
warnings.splice(idx, 1);
|
|
264
|
-
return true;
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Walk every type reference inside the user's Props annotation, find the
|
|
268
|
-
* matching ImportDeclaration in instance/module scripts, resolve the
|
|
269
|
-
* specifier via Node's resolver, locate sibling .d.ts files, and add them to
|
|
270
|
-
* the shared Project. Returns the number of newly-added files.
|
|
271
|
-
*/
|
|
272
|
-
function addImportedDeclarationsToProject(project, ctx) {
|
|
273
|
-
// Start with names referenced directly in the annotation (e.g. `Props`).
|
|
274
|
-
const importedNames = collectReferencedTypeNames(ctx.annotation);
|
|
275
|
-
// Also pull in names referenced from any LOCAL type/interface declaration the
|
|
276
|
-
// annotation points at — that's where heritage clauses (`extends FakeProps`)
|
|
277
|
-
// and intersections live, and they're the ones that name the imported type
|
|
278
|
-
// we actually need to add to the project.
|
|
279
|
-
for (const name of [...importedNames]) {
|
|
280
|
-
const localDecl = findLocalTypeDeclaration(ctx.instance, name, ctx.moduleScript) ??
|
|
281
|
-
(ctx.moduleScript ? findLocalTypeDeclaration(ctx.moduleScript, name, ctx.instance) : null);
|
|
282
|
-
if (localDecl) {
|
|
283
|
-
for (const referenced of collectReferencedTypeNames(localDecl))
|
|
284
|
-
importedNames.add(referenced);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (importedNames.size === 0)
|
|
288
|
-
return 0;
|
|
289
|
-
const imports = collectImportSpecifiersForNames(ctx.instance, importedNames);
|
|
290
|
-
if (ctx.moduleScript) {
|
|
291
|
-
for (const [k, v] of collectImportSpecifiersForNames(ctx.moduleScript, importedNames))
|
|
292
|
-
imports.set(k, v);
|
|
293
|
-
}
|
|
294
|
-
if (imports.size === 0)
|
|
295
|
-
return 0;
|
|
296
|
-
const req = createRequire(ctx.filePath);
|
|
297
|
-
let added = 0;
|
|
298
|
-
for (const specifier of imports.values()) {
|
|
299
|
-
if (specifier.startsWith('.'))
|
|
300
|
-
continue; // already attempted by relative resolver path
|
|
301
|
-
const dtsPath = locateDtsForSpecifier(req, specifier, ctx.filePath);
|
|
302
|
-
if (!dtsPath)
|
|
303
|
-
continue;
|
|
304
|
-
try {
|
|
305
|
-
const sf = project.addSourceFileAtPathIfExists(dtsPath);
|
|
306
|
-
if (sf)
|
|
307
|
-
added++;
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
// Ignore — best-effort.
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
return added;
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* Walk a TS type-annotation AST (or interface declaration) and gather every
|
|
317
|
-
* referenced type name. Handles:
|
|
318
|
-
* - TSTypeReference (`Foo`, `Foo<Bar>`)
|
|
319
|
-
* - TSExpressionWithTypeArguments (the `extends Foo` form on interfaces)
|
|
320
|
-
* That second case is critical: it's how `interface Props extends FakeProps {}`
|
|
321
|
-
* surfaces the imported `FakeProps` name we need to add to the project.
|
|
322
|
-
*/
|
|
323
|
-
function collectReferencedTypeNames(node) {
|
|
324
|
-
const names = new Set();
|
|
325
|
-
walk(node);
|
|
326
|
-
return names;
|
|
327
|
-
function walk(n) {
|
|
328
|
-
if (!n || typeof n !== 'object')
|
|
329
|
-
return;
|
|
330
|
-
if (n.type === 'TSTypeReference') {
|
|
331
|
-
const tn = n['typeName']?.['name'];
|
|
332
|
-
if (tn)
|
|
333
|
-
names.add(tn);
|
|
334
|
-
}
|
|
335
|
-
if (n.type === 'TSExpressionWithTypeArguments') {
|
|
336
|
-
const exprName = n['expression']?.['name'];
|
|
337
|
-
if (exprName)
|
|
338
|
-
names.add(exprName);
|
|
339
|
-
}
|
|
340
|
-
for (const value of Object.values(n)) {
|
|
341
|
-
if (Array.isArray(value)) {
|
|
342
|
-
for (const v of value) {
|
|
343
|
-
if (v && typeof v === 'object')
|
|
344
|
-
walk(v);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
else if (value && typeof value === 'object' && value.type) {
|
|
348
|
-
walk(value);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* For each named identifier in `names`, find the import declaration that
|
|
355
|
-
* brought it into scope and return a map of `localName -> moduleSpecifier`.
|
|
356
|
-
*/
|
|
357
|
-
function collectImportSpecifiersForNames(script, names) {
|
|
358
|
-
const out = new Map();
|
|
359
|
-
const body = script['content']?.['body'];
|
|
360
|
-
if (!body)
|
|
361
|
-
return out;
|
|
362
|
-
for (const stmt of body) {
|
|
363
|
-
if (stmt.type !== 'ImportDeclaration')
|
|
364
|
-
continue;
|
|
365
|
-
const specifierValue = stmt['source']?.['value'];
|
|
366
|
-
if (!specifierValue)
|
|
367
|
-
continue;
|
|
368
|
-
const specifiers = stmt['specifiers'] ?? [];
|
|
369
|
-
for (const spec of specifiers) {
|
|
370
|
-
if (spec.type !== 'ImportSpecifier' && spec.type !== 'ImportDefaultSpecifier')
|
|
371
|
-
continue;
|
|
372
|
-
const localName = spec['local']?.['name'];
|
|
373
|
-
if (localName && names.has(localName))
|
|
374
|
-
out.set(localName, specifierValue);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return out;
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Locate a `.d.ts` file for `specifier` resolved relative to `parentFile`.
|
|
381
|
-
* Tries a few strategies in priority order:
|
|
382
|
-
* 1. `package.json#types` / `package.json#exports.types`
|
|
383
|
-
* 2. sibling `.d.ts` / `.d.mts` next to the resolved JS entry
|
|
384
|
-
* 3. `index.d.ts` / `index.d.mts` in the package root
|
|
385
|
-
* Returns null if nothing resolves (no node_modules, dynamic import, etc.).
|
|
386
|
-
*/
|
|
387
|
-
function locateDtsForSpecifier(req, specifier, parentFile) {
|
|
388
|
-
let resolvedJs = null;
|
|
389
|
-
try {
|
|
390
|
-
resolvedJs = req.resolve(specifier);
|
|
391
|
-
}
|
|
392
|
-
catch {
|
|
393
|
-
resolvedJs = null;
|
|
394
|
-
}
|
|
395
|
-
// Find the package root (nearest package.json above the resolved file or above the parentFile).
|
|
396
|
-
const seedDir = resolvedJs ? dirname(resolvedJs) : dirname(parentFile);
|
|
397
|
-
const pkgRoot = findPackageRootForSpecifier(seedDir, specifier);
|
|
398
|
-
if (pkgRoot) {
|
|
399
|
-
const pkgJsonPath = join(pkgRoot, 'package.json');
|
|
400
|
-
if (existsSync(pkgJsonPath)) {
|
|
401
|
-
try {
|
|
402
|
-
const pkgRaw = readFileSync(pkgJsonPath, 'utf-8');
|
|
403
|
-
const pkg = JSON.parse(pkgRaw);
|
|
404
|
-
const typesField = pkg.types ?? pkg.typings;
|
|
405
|
-
if (typeof typesField === 'string') {
|
|
406
|
-
const candidate = resolve(pkgRoot, typesField);
|
|
407
|
-
if (existsSync(candidate))
|
|
408
|
-
return candidate;
|
|
409
|
-
}
|
|
410
|
-
const exportsTypes = extractTypesFromExports(pkg.exports);
|
|
411
|
-
if (exportsTypes) {
|
|
412
|
-
const candidate = resolve(pkgRoot, exportsTypes);
|
|
413
|
-
if (existsSync(candidate))
|
|
414
|
-
return candidate;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
catch {
|
|
418
|
-
// Fall through to sibling probing.
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
// index.d.ts at the package root.
|
|
422
|
-
for (const entry of ['index.d.ts', 'index.d.mts']) {
|
|
423
|
-
const candidate = join(pkgRoot, entry);
|
|
424
|
-
if (existsSync(candidate))
|
|
425
|
-
return candidate;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// Sibling probing next to the resolved JS file.
|
|
429
|
-
if (resolvedJs) {
|
|
430
|
-
const noExt = resolvedJs.replace(/\.(m?js|cjs)$/, '');
|
|
431
|
-
for (const ext of ['.d.ts', '.d.mts']) {
|
|
432
|
-
const candidate = `${noExt}${ext}`;
|
|
433
|
-
if (existsSync(candidate))
|
|
434
|
-
return candidate;
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
return null;
|
|
438
|
-
}
|
|
439
|
-
function findPackageRootForSpecifier(seedDir, specifier) {
|
|
440
|
-
// For scoped (@x/y) and bare specifiers, the package root is the directory
|
|
441
|
-
// whose path ends with the specifier under a node_modules tree. Walk up.
|
|
442
|
-
let dir = seedDir;
|
|
443
|
-
for (let i = 0; i < 32; i++) {
|
|
444
|
-
if (existsSync(join(dir, 'package.json'))) {
|
|
445
|
-
// Check this is the package matching the specifier (best-effort: name field).
|
|
446
|
-
try {
|
|
447
|
-
const pkgRaw = readFileSync(join(dir, 'package.json'), 'utf-8');
|
|
448
|
-
const pkg = JSON.parse(pkgRaw);
|
|
449
|
-
// Either exact match or a sub-path import (foo/sub) where pkg.name === 'foo'.
|
|
450
|
-
if (pkg.name === specifier)
|
|
451
|
-
return dir;
|
|
452
|
-
if (pkg.name && specifier.startsWith(`${pkg.name}/`))
|
|
453
|
-
return dir;
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
// Ignore and continue walking.
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
const parent = dirname(dir);
|
|
460
|
-
if (!parent || parent === dir)
|
|
461
|
-
return null;
|
|
462
|
-
dir = parent;
|
|
463
|
-
}
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
function extractTypesFromExports(exportsField) {
|
|
467
|
-
if (!exportsField || typeof exportsField !== 'object')
|
|
468
|
-
return null;
|
|
469
|
-
const exp = exportsField;
|
|
470
|
-
// Look for the "." entry first; fall back to top-level types if shape is conditional.
|
|
471
|
-
const root = (exp['.'] ?? exp);
|
|
472
|
-
if (!root || typeof root !== 'object')
|
|
473
|
-
return null;
|
|
474
|
-
const r = root;
|
|
475
|
-
if (typeof r['types'] === 'string')
|
|
476
|
-
return r['types'];
|
|
477
|
-
// Conditional exports: try import → types, default → types.
|
|
478
|
-
for (const key of ['import', 'default', 'node']) {
|
|
479
|
-
const sub = r[key];
|
|
480
|
-
if (sub && typeof sub === 'object') {
|
|
481
|
-
const t = sub['types'];
|
|
482
|
-
if (typeof t === 'string')
|
|
483
|
-
return t;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
/**
|
|
489
|
-
* When a large fraction of components emit the same `declared Props type ...
|
|
490
|
-
* resolved to ... properties` warning (typical of headless libraries like
|
|
491
|
-
* skeleton-svelte that extend types from external packages we can't reach),
|
|
492
|
-
* replace ALL of them with a single summary line. Per-component
|
|
493
|
-
* reviewReasons + needsReview flags stay intact, so the user can still
|
|
494
|
-
* drill into any individual component to see the issue — we just don't
|
|
495
|
-
* flood the top-level warnings panel with N near-identical lines.
|
|
496
|
-
*
|
|
497
|
-
* Threshold: 3 or more identical-shape warnings. Below that, the literal
|
|
498
|
-
* per-component lines are short enough to keep as-is.
|
|
499
|
-
*/
|
|
500
|
-
function collapseUnresolvedTypeWarnings(warnings) {
|
|
501
|
-
const isUnresolvedWarning = (w) => /declared Props type .* resolved to/.test(w);
|
|
502
|
-
const unresolvedCount = warnings.filter(isUnresolvedWarning).length;
|
|
503
|
-
if (unresolvedCount < 3)
|
|
504
|
-
return warnings;
|
|
505
|
-
const others = warnings.filter((w) => !isUnresolvedWarning(w));
|
|
506
|
-
const summary = `Unresolved component types: ${unresolvedCount} components have a declared Props type the parser couldn't fully resolve — ` +
|
|
507
|
-
`most often a cross-package extends pattern (e.g. an interface that extends a type from a node_modules package). ` +
|
|
508
|
-
`Each affected component is flagged with reviewReasons: ['props-type-unresolved'] and needsReview = true; ` +
|
|
509
|
-
`select one in the TUI to drill in. ` +
|
|
510
|
-
`See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`;
|
|
511
|
-
return [summary, ...others];
|
|
512
|
-
}
|
|
513
|
-
async function extractFromSvelteFile(filePath, source) {
|
|
514
|
-
const warnings = [];
|
|
515
|
-
// Derive the component name up front so warning messages can prefix it for
|
|
516
|
-
// TUI grouping, even when parsing fails.
|
|
517
|
-
const name = getSvelteComponentName(filePath);
|
|
518
|
-
let ast;
|
|
519
|
-
try {
|
|
520
|
-
ast = parseSvelte(source, { modern: true });
|
|
521
|
-
}
|
|
522
|
-
catch (e) {
|
|
523
|
-
return {
|
|
524
|
-
component: null,
|
|
525
|
-
warnings: [`${name}: parse error in ${filePath}: ${e instanceof Error ? e.message : String(e)}`],
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
const instance = ast['instance'];
|
|
529
|
-
const moduleScript = ast['module'];
|
|
530
|
-
const fragment = ast['fragment'];
|
|
531
|
-
// Detect Svelte 4 export-let syntax — currently unsupported.
|
|
532
|
-
if (instance && hasV4ExportLetProps(instance)) {
|
|
533
|
-
warnings.push(`${name}: Svelte 4 export let syntax not yet supported (${filePath}); see INTEG-4267 for v5-only scope and follow-up`);
|
|
534
|
-
return { component: null, warnings };
|
|
535
|
-
}
|
|
536
|
-
// Find the $props() call (Svelte 5 runes).
|
|
537
|
-
const propsCall = instance ? findPropsCall(instance) : null;
|
|
538
|
-
// Snippet import map: localName -> true if it refers to `Snippet` from 'svelte'.
|
|
539
|
-
const snippetLocals = instance ? collectSnippetImportLocals(instance) : new Set();
|
|
540
|
-
// --- Props extraction ---
|
|
541
|
-
let props = [];
|
|
542
|
-
let propNamesTreatedAsSnippet = new Set();
|
|
543
|
-
let snippetSlotsFromProps = [];
|
|
544
|
-
const extractionReasons = [];
|
|
545
|
-
if (propsCall) {
|
|
546
|
-
const result = await extractPropsFromCall({
|
|
547
|
-
propsCall,
|
|
548
|
-
instance: instance,
|
|
549
|
-
moduleScript,
|
|
550
|
-
filePath,
|
|
551
|
-
componentName: name,
|
|
552
|
-
source,
|
|
553
|
-
snippetLocals,
|
|
554
|
-
});
|
|
555
|
-
props = result.props;
|
|
556
|
-
propNamesTreatedAsSnippet = result.snippetNames;
|
|
557
|
-
snippetSlotsFromProps = result.snippetSlots;
|
|
558
|
-
warnings.push(...result.warnings);
|
|
559
|
-
if (result.additionalReasons)
|
|
560
|
-
extractionReasons.push(...result.additionalReasons);
|
|
561
|
-
}
|
|
562
|
-
else if (instance) {
|
|
563
|
-
// No $props() call. We've already ruled out v4 above; this means $props was used
|
|
564
|
-
// but couldn't be located, or the script has no rune-style props at all.
|
|
565
|
-
// Component still extracted (slots from template may exist).
|
|
566
|
-
}
|
|
567
|
-
else {
|
|
568
|
-
// No script block at all — nothing to extract on the props side.
|
|
569
|
-
warnings.push(`${name}: no instance script block (${filePath}); no props extracted`);
|
|
570
|
-
}
|
|
571
|
-
// --- Slot extraction ---
|
|
572
|
-
const templateSlots = fragment ? extractTemplateSlots(fragment) : [];
|
|
573
|
-
const { slots, mixedWarning } = mergeSlots(snippetSlotsFromProps, templateSlots);
|
|
574
|
-
if (mixedWarning) {
|
|
575
|
-
warnings.push(`${name}: mixed Snippet and <slot> usage detected (${filePath}); preferring Snippet entries`);
|
|
576
|
-
}
|
|
577
|
-
// Capture the props-type-name (if any) so the run-level post-pass can build
|
|
578
|
-
// a props-type → component-name map for $allowedComponents resolution
|
|
579
|
-
// (mirrors the React extractor's `_propsTypeName` mechanism).
|
|
580
|
-
let propsTypeNameCapture;
|
|
581
|
-
if (propsCall) {
|
|
582
|
-
const id = propsCall['id'];
|
|
583
|
-
const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
|
|
584
|
-
if (annotation?.type === 'TSTypeReference') {
|
|
585
|
-
const tn = annotation['typeName']?.['name'];
|
|
586
|
-
if (tn && /^[A-Za-z_$][\w$]*$/.test(tn))
|
|
587
|
-
propsTypeNameCapture = tn;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
const component = {
|
|
591
|
-
name,
|
|
592
|
-
source: filePath,
|
|
593
|
-
framework: 'svelte',
|
|
594
|
-
props,
|
|
595
|
-
slots,
|
|
596
|
-
...(propsTypeNameCapture ? { _propsTypeName: propsTypeNameCapture } : {}),
|
|
597
|
-
};
|
|
598
|
-
// Score & flag review. Forward extraction-time reasons (e.g. props-type-unresolved)
|
|
599
|
-
// so they count toward the confidence score AND surface in reviewReasons.
|
|
600
|
-
const score = computeExtractionScore(component, {
|
|
601
|
-
additionalIssueCount: extractionReasons.length,
|
|
602
|
-
additionalReasons: extractionReasons,
|
|
603
|
-
});
|
|
604
|
-
component.extractionConfidence = score.confidence;
|
|
605
|
-
component.reviewReasons = score.reasons;
|
|
606
|
-
// A type-resolution failure is a strong signal something is wrong; force
|
|
607
|
-
// human review even when other heuristics keep confidence above the threshold.
|
|
608
|
-
component.needsReview = deriveNeedsReview(score.confidence) || extractionReasons.includes('props-type-unresolved');
|
|
609
|
-
// Validation issues (EMPTY_COMPONENT_NAME / EMPTY_PROP_NAME / PROP_SLOT_NAME_COLLISION /
|
|
610
|
-
// DUPLICATE_COMPONENT_NAME / EMPTY_COMPONENT / EMPTY_SLOT_NAME) are populated
|
|
611
|
-
// centrally by validateExtractedComponents() in analyze/command.ts after all
|
|
612
|
-
// extractors return — same convention as React, Vue, Astro, Stencil, and
|
|
613
|
-
// web-components. No per-extractor work needed here.
|
|
614
|
-
void propNamesTreatedAsSnippet; // currently informational; reserved for future validation
|
|
615
|
-
// Capture context for the resolve-unreachable retry pass. Only meaningful
|
|
616
|
-
// when extraction actually got back the props-type-unresolved signal AND
|
|
617
|
-
// we have the annotation node needed to re-run resolution.
|
|
618
|
-
let retryContext;
|
|
619
|
-
if (extractionReasons.includes('props-type-unresolved') && propsCall && instance) {
|
|
620
|
-
const id = propsCall['id'];
|
|
621
|
-
const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
|
|
622
|
-
if (annotation) {
|
|
623
|
-
retryContext = {
|
|
624
|
-
filePath,
|
|
625
|
-
source,
|
|
626
|
-
instance,
|
|
627
|
-
moduleScript,
|
|
628
|
-
annotation,
|
|
629
|
-
componentName: name,
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
return { component, warnings, ...(retryContext ? { retryContext } : {}) };
|
|
634
|
-
}
|
|
635
|
-
// ---------------------------------------------------------------------------
|
|
636
|
-
// Component name
|
|
637
|
-
// ---------------------------------------------------------------------------
|
|
638
|
-
// Folder names that are pure scaffolding (anatomy / parts conventions popularized
|
|
639
|
-
// by Ark UI, Zag, Skeleton). When a component file sits directly inside one of
|
|
640
|
-
// these, the meaningful namespace is the grandparent directory.
|
|
641
|
-
const ANATOMY_FOLDERS = new Set(['anatomy', 'parts']);
|
|
642
|
-
function getSvelteComponentName(filePath) {
|
|
643
|
-
const file = basename(filePath, '.svelte');
|
|
644
|
-
const parentDir = basename(dirname(filePath));
|
|
645
|
-
// index.svelte → use parent directory name (mirrors index.vue behavior).
|
|
646
|
-
if (file === 'index')
|
|
647
|
-
return toPascalCase(parentDir);
|
|
648
|
-
// accordion/anatomy/root.svelte → AccordionRoot (avoids massive collisions
|
|
649
|
-
// when a single library has dozens of components named Root/Item/Trigger).
|
|
650
|
-
if (ANATOMY_FOLDERS.has(parentDir)) {
|
|
651
|
-
const grandparent = basename(dirname(dirname(filePath)));
|
|
652
|
-
if (grandparent && grandparent !== '.' && grandparent !== '/') {
|
|
653
|
-
return `${toPascalCase(grandparent)}${toPascalCase(file)}`;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
return toPascalCase(file);
|
|
657
|
-
}
|
|
658
|
-
function toPascalCase(s) {
|
|
659
|
-
if (!s)
|
|
660
|
-
return s;
|
|
661
|
-
// Already PascalCase? leave it.
|
|
662
|
-
if (/^[A-Z]/.test(s) && !s.includes('-') && !s.includes('_'))
|
|
663
|
-
return s;
|
|
664
|
-
return s
|
|
665
|
-
.split(/[-_]+/)
|
|
666
|
-
.filter(Boolean)
|
|
667
|
-
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
668
|
-
.join('');
|
|
669
|
-
}
|
|
670
|
-
// ---------------------------------------------------------------------------
|
|
671
|
-
// Detection helpers
|
|
672
|
-
// ---------------------------------------------------------------------------
|
|
673
|
-
function hasV4ExportLetProps(instance) {
|
|
674
|
-
const body = instance['content']?.['body'];
|
|
675
|
-
if (!body)
|
|
676
|
-
return false;
|
|
677
|
-
return body.some((stmt) => {
|
|
678
|
-
if (stmt.type !== 'ExportNamedDeclaration')
|
|
679
|
-
return false;
|
|
680
|
-
const decl = stmt['declaration'];
|
|
681
|
-
return decl?.type === 'VariableDeclaration' && decl['kind'] === 'let';
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
function findPropsCall(instance) {
|
|
685
|
-
const body = instance['content']?.['body'];
|
|
686
|
-
if (!body)
|
|
687
|
-
return null;
|
|
688
|
-
let found = null;
|
|
689
|
-
for (const stmt of body) {
|
|
690
|
-
if (stmt.type !== 'VariableDeclaration')
|
|
691
|
-
continue;
|
|
692
|
-
const decls = stmt['declarations'];
|
|
693
|
-
if (!decls)
|
|
694
|
-
continue;
|
|
695
|
-
for (const d of decls) {
|
|
696
|
-
const init = d['init'];
|
|
697
|
-
if (!init || init.type !== 'CallExpression')
|
|
698
|
-
continue;
|
|
699
|
-
const callee = init['callee'];
|
|
700
|
-
if (callee?.type === 'Identifier' && callee['name'] === '$props') {
|
|
701
|
-
if (found === null)
|
|
702
|
-
found = d;
|
|
703
|
-
// Multiple $props() calls — first wins; we'll warn from the caller.
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
return found;
|
|
708
|
-
}
|
|
709
|
-
function collectSnippetImportLocals(instance) {
|
|
710
|
-
const locals = new Set();
|
|
711
|
-
const body = instance['content']?.['body'];
|
|
712
|
-
if (!body)
|
|
713
|
-
return locals;
|
|
714
|
-
for (const stmt of body) {
|
|
715
|
-
if (stmt.type !== 'ImportDeclaration')
|
|
716
|
-
continue;
|
|
717
|
-
const sourceNode = stmt['source'];
|
|
718
|
-
if (sourceNode?.['value'] !== 'svelte')
|
|
719
|
-
continue;
|
|
720
|
-
const specs = stmt['specifiers'];
|
|
721
|
-
if (!specs)
|
|
722
|
-
continue;
|
|
723
|
-
for (const spec of specs) {
|
|
724
|
-
if (spec.type !== 'ImportSpecifier')
|
|
725
|
-
continue;
|
|
726
|
-
const imported = spec['imported'];
|
|
727
|
-
const local = spec['local'];
|
|
728
|
-
if (imported?.['name'] === 'Snippet' && typeof local?.['name'] === 'string') {
|
|
729
|
-
locals.add(local['name']);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
return locals;
|
|
734
|
-
}
|
|
735
|
-
async function extractPropsFromCall(ctx) {
|
|
736
|
-
const warnings = [];
|
|
737
|
-
const propsCall = ctx.propsCall;
|
|
738
|
-
const id = propsCall['id'];
|
|
739
|
-
const idType = id?.type;
|
|
740
|
-
// Decode the type annotation (the right side of `: Props` / `: { ... }`).
|
|
741
|
-
const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
|
|
742
|
-
// Resolve the type members once (works for both ObjectPattern and Identifier id forms).
|
|
743
|
-
const typeMembers = annotation
|
|
744
|
-
? await resolveTypeMembers(annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source)
|
|
745
|
-
: null;
|
|
746
|
-
// Detect "we tried to resolve a real type and got nothing back" — distinct from
|
|
747
|
-
// the user genuinely typing an empty literal. Surface as both a warning (CLI
|
|
748
|
-
// stderr / analyze report) and a review reason (TUI / downstream gating).
|
|
749
|
-
const unresolved = classifyUnresolved(annotation, typeMembers, ctx.instance, ctx.moduleScript);
|
|
750
|
-
const additionalReasons = [];
|
|
751
|
-
if (unresolved) {
|
|
752
|
-
const refLabel = describeAnnotationForUser(annotation);
|
|
753
|
-
const heritageNote = unresolved === 'partial-heritage' ? ' (heritage clauses extending unreachable types)' : '';
|
|
754
|
-
warnings.push(`${ctx.componentName}: declared Props type ${refLabel} resolved to ${unresolved === 'empty' ? '0' : 'only Snippet-typed'} properties${heritageNote} (${ctx.filePath}) — possible cross-package extends or unreachable type. ` +
|
|
755
|
-
`See https://github.com/contentful/experience-design-system-sdk-public/pull/44 for context and partner workarounds.`);
|
|
756
|
-
additionalReasons.push('props-type-unresolved');
|
|
757
|
-
}
|
|
758
|
-
if (idType === 'ObjectPattern') {
|
|
759
|
-
return extractFromDestructure(ctx.propsCall, ctx, typeMembers, warnings, additionalReasons);
|
|
760
|
-
}
|
|
761
|
-
if (idType === 'Identifier') {
|
|
762
|
-
// const props: Props = $props(); — no destructure, no defaults, no per-name binding.
|
|
763
|
-
if (typeMembers && typeMembers.length > 0) {
|
|
764
|
-
return { ...extractFromTypeMembersOnly(typeMembers), warnings, additionalReasons };
|
|
765
|
-
}
|
|
766
|
-
if (!unresolved) {
|
|
767
|
-
warnings.push(`${ctx.componentName}: $props() called without destructuring (${ctx.filePath}); cannot extract individual props`);
|
|
768
|
-
}
|
|
769
|
-
return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
|
|
770
|
-
}
|
|
771
|
-
warnings.push(`${ctx.componentName}: unrecognized $props() binding pattern '${idType}' (${ctx.filePath})`);
|
|
772
|
-
return { props: [], snippetNames: new Set(), snippetSlots: [], warnings, additionalReasons };
|
|
773
|
-
}
|
|
774
|
-
/**
|
|
775
|
-
* Classify whether the user's declared Props type failed to resolve usefully.
|
|
776
|
-
*
|
|
777
|
-
* - `'empty'`: the resolver returned no members for a non-trivial annotation.
|
|
778
|
-
* - `'partial-heritage'`: the source declaration has heritage clauses (extends /
|
|
779
|
-
* intersection in module-script) but the resolver only surfaced Snippet-typed
|
|
780
|
-
* members — strong signal that one or more parents pointed at unreachable
|
|
781
|
-
* types (e.g. cross-package node_modules) and were silently dropped.
|
|
782
|
-
* - `null`: nothing to surface — either the user genuinely typed an empty
|
|
783
|
-
* literal, omitted the annotation, or the resolver returned regular props.
|
|
784
|
-
*/
|
|
785
|
-
function classifyUnresolved(annotation, members, instance, moduleScript) {
|
|
786
|
-
if (!annotation)
|
|
787
|
-
return null;
|
|
788
|
-
if (annotation.type === 'TSTypeLiteral') {
|
|
789
|
-
const litMembers = annotation['members'] ?? [];
|
|
790
|
-
if (litMembers.length === 0)
|
|
791
|
-
return null; // user genuinely typed `{}`
|
|
792
|
-
}
|
|
793
|
-
if (members === null || members.length === 0)
|
|
794
|
-
return 'empty';
|
|
795
|
-
// Partial-heritage signal: declaration has extends and every surviving member
|
|
796
|
-
// is Snippet-typed. Real prop interfaces almost never declare every prop as a
|
|
797
|
-
// Snippet, so this is a strong "something fell off the resolution edge" hint.
|
|
798
|
-
if (annotation.type === 'TSTypeReference') {
|
|
799
|
-
const refName = annotation['typeName']?.['name'] ?? null;
|
|
800
|
-
if (refName) {
|
|
801
|
-
const decl = findLocalTypeDeclaration(instance, refName, moduleScript);
|
|
802
|
-
if (decl && declarationHasHeritage(decl) && members.every((m) => m.isSnippet)) {
|
|
803
|
-
return 'partial-heritage';
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
return null;
|
|
808
|
-
}
|
|
809
|
-
function describeAnnotationForUser(annotation) {
|
|
810
|
-
if (!annotation)
|
|
811
|
-
return '<unknown>';
|
|
812
|
-
if (annotation.type === 'TSTypeReference') {
|
|
813
|
-
const name = annotation['typeName']?.['name'] ?? null;
|
|
814
|
-
return name ? `'${name}'` : '<unnamed reference>';
|
|
815
|
-
}
|
|
816
|
-
if (annotation.type === 'TSIntersectionType')
|
|
817
|
-
return '<intersection>';
|
|
818
|
-
if (annotation.type === 'TSUnionType')
|
|
819
|
-
return '<union>';
|
|
820
|
-
if (annotation.type === 'TSTypeLiteral')
|
|
821
|
-
return '<inline literal>';
|
|
822
|
-
return `<${annotation.type}>`;
|
|
823
|
-
}
|
|
824
|
-
async function resolveTypeMembers(annotation, instance, moduleScript, filePath, source) {
|
|
825
|
-
// Snippet imports may live in either script block; collect from both.
|
|
826
|
-
const snippetLocals = mergeSets(collectSnippetImportLocals(instance), moduleScript ? collectSnippetImportLocals(moduleScript) : new Set());
|
|
827
|
-
// Fast path 1: inline type literal with no extends/intersection. The AST already
|
|
828
|
-
// gives us member-level optional/type/JSDoc; no ts-morph needed.
|
|
829
|
-
if (annotation.type === 'TSTypeLiteral') {
|
|
830
|
-
return readMembersFromTypeLiteral(annotation, snippetLocals);
|
|
831
|
-
}
|
|
832
|
-
// For named refs: try the AST fast path first (inline interface or type literal alias
|
|
833
|
-
// with no heritage clauses). If that returns >0 members AND the source declaration has
|
|
834
|
-
// no extends, we trust it. Otherwise fall back to ts-morph type-checker resolution to
|
|
835
|
-
// pick up extends / Omit / intersection / Partial / generics.
|
|
836
|
-
if (annotation.type === 'TSTypeReference') {
|
|
837
|
-
const refName = annotation['typeName']?.['name'] ?? null;
|
|
838
|
-
if (refName) {
|
|
839
|
-
const local = findLocalTypeDeclaration(instance, refName, moduleScript);
|
|
840
|
-
if (local) {
|
|
841
|
-
const fastPathMembers = readMembersFromInterfaceOrAlias(local, snippetLocals);
|
|
842
|
-
if (fastPathMembers.length > 0 && !declarationHasHeritage(local)) {
|
|
843
|
-
return fastPathMembers;
|
|
844
|
-
}
|
|
845
|
-
// Heritage or empty body — fall through to ts-morph resolution.
|
|
846
|
-
}
|
|
847
|
-
else {
|
|
848
|
-
// No local declaration — try import resolution via ts-morph.
|
|
849
|
-
const imported = (await resolveImportedTypeMembers(refName, instance, filePath)) ??
|
|
850
|
-
(moduleScript ? await resolveImportedTypeMembers(refName, moduleScript, filePath) : null);
|
|
851
|
-
if (imported)
|
|
852
|
-
return imported;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
// Slow path: ts-morph type-checker resolution. Materializes the annotation text as
|
|
857
|
-
// `type __SveltePropsT__ = <annotation>;` inside an in-memory file containing the
|
|
858
|
-
// combined script bodies, then reads .getType().getProperties(). The TS checker
|
|
859
|
-
// resolves extends, &, Omit, Pick, Partial, generics — anything TS itself resolves.
|
|
860
|
-
return resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals);
|
|
861
|
-
}
|
|
862
|
-
function declarationHasHeritage(decl) {
|
|
863
|
-
if (decl.type === 'TSInterfaceDeclaration') {
|
|
864
|
-
const ext = decl['extends'];
|
|
865
|
-
return Array.isArray(ext) && ext.length > 0;
|
|
866
|
-
}
|
|
867
|
-
return false;
|
|
868
|
-
}
|
|
869
|
-
async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals, externalProject) {
|
|
870
|
-
const annotationText = sliceSource(source, annotation);
|
|
871
|
-
if (!annotationText)
|
|
872
|
-
return null;
|
|
873
|
-
// Reconstruct the combined script content: module-script first, then instance.
|
|
874
|
-
// Both bodies share scope in the synthetic file, which is enough for TS to resolve
|
|
875
|
-
// local interfaces, type aliases, imports, and heritage clauses across them.
|
|
876
|
-
const moduleText = sliceScriptContent(source, moduleScript);
|
|
877
|
-
const instanceText = sliceScriptContent(source, instance);
|
|
878
|
-
const synthetic = [moduleText, instanceText, `type __SveltePropsT__ = ${annotationText};`].filter(Boolean).join('\n');
|
|
879
|
-
// Reuse the caller-supplied Project if provided (the resolve-unreachable
|
|
880
|
-
// retry pass shares one Project across all components in the run); otherwise
|
|
881
|
-
// build a one-off project for this single call.
|
|
882
|
-
const project = externalProject ??
|
|
883
|
-
new Project({
|
|
884
|
-
compilerOptions: {
|
|
885
|
-
strict: false,
|
|
886
|
-
target: ScriptTarget.ESNext,
|
|
887
|
-
module: ModuleKind.ESNext,
|
|
888
|
-
allowJs: true,
|
|
889
|
-
jsx: ts.JsxEmit.Preserve,
|
|
890
|
-
},
|
|
891
|
-
useInMemoryFileSystem: false,
|
|
892
|
-
skipAddingFilesFromTsConfig: true,
|
|
893
|
-
});
|
|
894
|
-
// Place the synthetic file alongside the .svelte so relative imports resolve.
|
|
895
|
-
const syntheticPath = `${filePath}.__svelte-props__.ts`;
|
|
896
|
-
let sf;
|
|
897
|
-
try {
|
|
898
|
-
sf = project.createSourceFile(syntheticPath, synthetic, { overwrite: true });
|
|
899
|
-
}
|
|
900
|
-
catch {
|
|
901
|
-
return null;
|
|
902
|
-
}
|
|
903
|
-
const alias = sf.getTypeAlias('__SveltePropsT__');
|
|
904
|
-
if (!alias)
|
|
905
|
-
return null;
|
|
906
|
-
const type = alias.getType();
|
|
907
|
-
const apparent = type.getApparentType();
|
|
908
|
-
const properties = apparent.getProperties();
|
|
909
|
-
if (properties.length === 0)
|
|
910
|
-
return null;
|
|
911
|
-
const members = [];
|
|
912
|
-
for (const symbol of properties) {
|
|
913
|
-
const name = symbol.getName();
|
|
914
|
-
const declaration = symbol.getValueDeclaration() ?? symbol.getDeclarations()[0];
|
|
915
|
-
if (!declaration)
|
|
916
|
-
continue;
|
|
917
|
-
const propType = symbol.getTypeAtLocation(declaration);
|
|
918
|
-
let typeText = propType.getText(declaration);
|
|
919
|
-
// Strip ` | undefined` for clean output on optional props.
|
|
920
|
-
const optional = symbol.isOptional();
|
|
921
|
-
if (optional) {
|
|
922
|
-
typeText = typeText.replace(/\s*\|\s*undefined$/, '').replace(/^undefined\s*\|\s*/, '');
|
|
923
|
-
}
|
|
924
|
-
const allowed = extractAllowedValuesFromType(propType);
|
|
925
|
-
const description = readJsDocFromDeclaration(declaration);
|
|
926
|
-
// Snippet detection — try three signals in order of reliability:
|
|
927
|
-
// 1. The author's declared type-node text on the property's source declaration.
|
|
928
|
-
// This survives even when ts-morph fails to fully resolve a generic
|
|
929
|
-
// instantiation and falls back to `any`/`unknown` (intermittent under
|
|
930
|
-
// partial type-checker state).
|
|
931
|
-
// 2. The resolved type's alias-symbol chain — works when ts-morph DID fully
|
|
932
|
-
// resolve the type (`Snippet<[T]>` expanded to its call signature).
|
|
933
|
-
// 3. Text-match on the rendered type. Last-resort fallback.
|
|
934
|
-
const declaredTypeText = readDeclaredTypeNodeText(declaration);
|
|
935
|
-
const isSnippet = (declaredTypeText !== null && isSnippetTypeText(declaredTypeText, snippetLocals)) ||
|
|
936
|
-
typeRefersToSnippet(propType) ||
|
|
937
|
-
isSnippetTypeText(typeText, snippetLocals);
|
|
938
|
-
members.push({
|
|
939
|
-
name,
|
|
940
|
-
optional,
|
|
941
|
-
typeText,
|
|
942
|
-
...(declaredTypeText ? { declaredTypeText } : {}),
|
|
943
|
-
isSnippet,
|
|
944
|
-
...(allowed ? { allowedValues: allowed } : {}),
|
|
945
|
-
...(description ? { description } : {}),
|
|
946
|
-
// line/endLine: prefer AST-derived location when the source declaration is in our
|
|
947
|
-
// svelte file. ts-morph's synthetic location refers to the synthetic file and
|
|
948
|
-
// isn't useful for the user.
|
|
949
|
-
});
|
|
950
|
-
}
|
|
951
|
-
return members;
|
|
952
|
-
}
|
|
953
|
-
function sliceSource(source, node) {
|
|
954
|
-
const start = node['start'] ?? null;
|
|
955
|
-
const end = node['end'] ?? null;
|
|
956
|
-
if (start == null || end == null)
|
|
957
|
-
return null;
|
|
958
|
-
return source.slice(start, end);
|
|
959
|
-
}
|
|
960
|
-
function sliceScriptContent(source, script) {
|
|
961
|
-
if (!script)
|
|
962
|
-
return null;
|
|
963
|
-
const content = script['content'];
|
|
964
|
-
if (!content)
|
|
965
|
-
return null;
|
|
966
|
-
return sliceSource(source, content);
|
|
967
|
-
}
|
|
968
|
-
function extractAllowedValuesFromType(type) {
|
|
969
|
-
if (!type.isUnion())
|
|
970
|
-
return undefined;
|
|
971
|
-
const out = [];
|
|
972
|
-
for (const t of type.getUnionTypes()) {
|
|
973
|
-
if (!t.isStringLiteral())
|
|
974
|
-
return undefined;
|
|
975
|
-
out.push(t.getLiteralValueOrThrow());
|
|
976
|
-
}
|
|
977
|
-
return out.length > 0 ? out : undefined;
|
|
978
|
-
}
|
|
979
|
-
function readJsDocFromDeclaration(decl) {
|
|
980
|
-
if (Node.isPropertySignature(decl) || Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl)) {
|
|
981
|
-
const jsdocs = decl.getJsDocs();
|
|
982
|
-
if (jsdocs.length > 0)
|
|
983
|
-
return jsdocs[0].getDescription().trim() || undefined;
|
|
984
|
-
}
|
|
985
|
-
return undefined;
|
|
986
|
-
}
|
|
987
|
-
/**
|
|
988
|
-
* Read the literal text of the property's declared type annotation as the
|
|
989
|
-
* AUTHOR wrote it (e.g. `Snippet<[Attrs]>`), independent of what the TS
|
|
990
|
-
* checker resolved it to. This is critical because ts-morph's checker can
|
|
991
|
-
* intermittently fall back to `any`/`unknown` for cross-package generic
|
|
992
|
-
* instantiations, in which case the symbol-/alias-based detector has nothing
|
|
993
|
-
* to follow. The author's syntactic annotation is stable in either case.
|
|
994
|
-
*/
|
|
995
|
-
function readDeclaredTypeNodeText(decl) {
|
|
996
|
-
if (Node.isPropertySignature(decl)) {
|
|
997
|
-
return decl.getTypeNode()?.getText() ?? null;
|
|
998
|
-
}
|
|
999
|
-
return null;
|
|
1000
|
-
}
|
|
1001
|
-
function isSnippetTypeText(typeText, snippetLocals) {
|
|
1002
|
-
// Direct match against the local Snippet name (handles aliasing).
|
|
1003
|
-
for (const local of snippetLocals) {
|
|
1004
|
-
if (typeText === local || typeText.startsWith(`${local}<`))
|
|
1005
|
-
return true;
|
|
1006
|
-
}
|
|
1007
|
-
// Defensive fallback: TS may surface the canonical `Snippet` from the import.
|
|
1008
|
-
return typeText === 'Snippet' || typeText.startsWith('Snippet<');
|
|
1009
|
-
}
|
|
1010
|
-
function typeRefersToSnippet(propType) {
|
|
1011
|
-
const seen = new Set();
|
|
1012
|
-
let cursor = propType;
|
|
1013
|
-
while (cursor && !seen.has(cursor)) {
|
|
1014
|
-
seen.add(cursor);
|
|
1015
|
-
const aliasName = cursor.getAliasSymbol()?.getName();
|
|
1016
|
-
const symName = cursor.getSymbol()?.getName();
|
|
1017
|
-
if (aliasName === 'Snippet' || symName === 'Snippet') {
|
|
1018
|
-
const decl = cursor.getAliasSymbol()?.getDeclarations()[0] ?? cursor.getSymbol()?.getDeclarations()[0];
|
|
1019
|
-
const file = decl?.getSourceFile().getFilePath() ?? '';
|
|
1020
|
-
// Accept Snippet from anywhere named "svelte" in the path; users almost
|
|
1021
|
-
// never name an unrelated type "Snippet" in their own code, but the
|
|
1022
|
-
// path check guards against the rare false positive.
|
|
1023
|
-
if (file.includes('/svelte/') || file.includes('\\svelte\\') || file === '')
|
|
1024
|
-
return true;
|
|
1025
|
-
}
|
|
1026
|
-
// Strip optional `| undefined` and recurse into single-element unions
|
|
1027
|
-
// (covers `Snippet | undefined` etc.).
|
|
1028
|
-
if (cursor.isUnion()) {
|
|
1029
|
-
const nonUndef = cursor.getUnionTypes().filter((t) => !t.isUndefined() && !t.isNull());
|
|
1030
|
-
if (nonUndef.length === 1) {
|
|
1031
|
-
cursor = nonUndef[0];
|
|
1032
|
-
continue;
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
break;
|
|
1036
|
-
}
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
function mergeSets(a, b) {
|
|
1040
|
-
const out = new Set(a);
|
|
1041
|
-
for (const v of b)
|
|
1042
|
-
out.add(v);
|
|
1043
|
-
return out;
|
|
1044
|
-
}
|
|
1045
|
-
function findLocalTypeDeclaration(instance, name, module) {
|
|
1046
|
-
// Skeleton-svelte and similar libraries declare the Props interface in
|
|
1047
|
-
// <script lang="ts" module> and consume it in the regular <script>. Look
|
|
1048
|
-
// in both bodies. Type declarations may also be wrapped in
|
|
1049
|
-
// `export { ... }` statements (ExportNamedDeclaration with .declaration).
|
|
1050
|
-
for (const script of [instance, module]) {
|
|
1051
|
-
const body = script?.['content']?.['body'];
|
|
1052
|
-
if (!body)
|
|
1053
|
-
continue;
|
|
1054
|
-
for (const stmt of body) {
|
|
1055
|
-
const decl = stmt.type === 'ExportNamedDeclaration' ? (stmt['declaration'] ?? stmt) : stmt;
|
|
1056
|
-
if (decl.type === 'TSInterfaceDeclaration' || decl.type === 'TSTypeAliasDeclaration') {
|
|
1057
|
-
const id = decl['id'];
|
|
1058
|
-
if (id?.['name'] === name)
|
|
1059
|
-
return decl;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
return null;
|
|
1064
|
-
}
|
|
1065
|
-
function readMembersFromInterfaceOrAlias(decl, snippetLocals) {
|
|
1066
|
-
if (decl.type === 'TSInterfaceDeclaration') {
|
|
1067
|
-
const body = decl['body']?.['body'];
|
|
1068
|
-
if (!body)
|
|
1069
|
-
return [];
|
|
1070
|
-
return body.map((m) => readPropertySignature(m, snippetLocals)).filter((m) => !!m);
|
|
1071
|
-
}
|
|
1072
|
-
if (decl.type === 'TSTypeAliasDeclaration') {
|
|
1073
|
-
const ann = decl['typeAnnotation'];
|
|
1074
|
-
if (ann?.type === 'TSTypeLiteral')
|
|
1075
|
-
return readMembersFromTypeLiteral(ann, snippetLocals);
|
|
1076
|
-
}
|
|
1077
|
-
return [];
|
|
1078
|
-
}
|
|
1079
|
-
function readMembersFromTypeLiteral(literal, snippetLocals) {
|
|
1080
|
-
const members = literal['members'];
|
|
1081
|
-
if (!members)
|
|
1082
|
-
return [];
|
|
1083
|
-
return members.map((m) => readPropertySignature(m, snippetLocals)).filter((m) => !!m);
|
|
1084
|
-
}
|
|
1085
|
-
function readPropertySignature(member, snippetLocals) {
|
|
1086
|
-
if (member.type !== 'TSPropertySignature')
|
|
1087
|
-
return null;
|
|
1088
|
-
const key = member['key'];
|
|
1089
|
-
const name = key?.['name'] ?? null;
|
|
1090
|
-
if (!name)
|
|
1091
|
-
return null;
|
|
1092
|
-
const optional = !!member['optional'];
|
|
1093
|
-
const typeNode = member['typeAnnotation']?.['typeAnnotation'];
|
|
1094
|
-
const typeText = renderType(typeNode);
|
|
1095
|
-
const isSnippet = isSnippetType(typeNode, snippetLocals);
|
|
1096
|
-
const allowedValues = collectStringLiteralUnion(typeNode);
|
|
1097
|
-
// JSDoc / comments — `leadingComments` may be on the member or on the parent.
|
|
1098
|
-
const leading = member['leadingComments'] ?? [];
|
|
1099
|
-
const description = extractJsdocText(leading);
|
|
1100
|
-
return {
|
|
1101
|
-
name,
|
|
1102
|
-
optional,
|
|
1103
|
-
typeText,
|
|
1104
|
-
isSnippet,
|
|
1105
|
-
allowedValues,
|
|
1106
|
-
description,
|
|
1107
|
-
line: member.loc?.start?.line,
|
|
1108
|
-
endLine: member.loc?.end?.line,
|
|
1109
|
-
};
|
|
1110
|
-
}
|
|
1111
|
-
function isSnippetType(typeNode, snippetLocals) {
|
|
1112
|
-
if (!typeNode)
|
|
1113
|
-
return false;
|
|
1114
|
-
if (typeNode.type !== 'TSTypeReference')
|
|
1115
|
-
return false;
|
|
1116
|
-
const tn = typeNode['typeName']?.['name'];
|
|
1117
|
-
if (!tn)
|
|
1118
|
-
return false;
|
|
1119
|
-
return snippetLocals.has(tn);
|
|
1120
|
-
}
|
|
1121
|
-
function collectStringLiteralUnion(typeNode) {
|
|
1122
|
-
if (!typeNode)
|
|
1123
|
-
return undefined;
|
|
1124
|
-
if (typeNode.type !== 'TSUnionType')
|
|
1125
|
-
return undefined;
|
|
1126
|
-
const members = typeNode['types'];
|
|
1127
|
-
if (!members)
|
|
1128
|
-
return undefined;
|
|
1129
|
-
const out = [];
|
|
1130
|
-
for (const m of members) {
|
|
1131
|
-
if (m.type !== 'TSLiteralType')
|
|
1132
|
-
return undefined; // not a pure literal union
|
|
1133
|
-
const lit = m['literal'];
|
|
1134
|
-
const v = lit?.['value'];
|
|
1135
|
-
if (typeof v !== 'string')
|
|
1136
|
-
return undefined;
|
|
1137
|
-
out.push(v);
|
|
1138
|
-
}
|
|
1139
|
-
return out.length > 0 ? out : undefined;
|
|
1140
|
-
}
|
|
1141
|
-
function renderType(typeNode) {
|
|
1142
|
-
if (!typeNode)
|
|
1143
|
-
return 'unknown';
|
|
1144
|
-
switch (typeNode.type) {
|
|
1145
|
-
case 'TSStringKeyword':
|
|
1146
|
-
return 'string';
|
|
1147
|
-
case 'TSNumberKeyword':
|
|
1148
|
-
return 'number';
|
|
1149
|
-
case 'TSBooleanKeyword':
|
|
1150
|
-
return 'boolean';
|
|
1151
|
-
case 'TSAnyKeyword':
|
|
1152
|
-
return 'any';
|
|
1153
|
-
case 'TSUnknownKeyword':
|
|
1154
|
-
return 'unknown';
|
|
1155
|
-
case 'TSNullKeyword':
|
|
1156
|
-
return 'null';
|
|
1157
|
-
case 'TSUndefinedKeyword':
|
|
1158
|
-
return 'undefined';
|
|
1159
|
-
case 'TSVoidKeyword':
|
|
1160
|
-
return 'void';
|
|
1161
|
-
case 'TSArrayType': {
|
|
1162
|
-
return `${renderType(typeNode['elementType'])}[]`;
|
|
1163
|
-
}
|
|
1164
|
-
case 'TSTupleType': {
|
|
1165
|
-
// Tuple element types can be plain (`T`) or labeled (`name: T` →
|
|
1166
|
-
// TSNamedTupleMember). Render each element by drilling to the type.
|
|
1167
|
-
const elements = typeNode['elementTypes'] ?? [];
|
|
1168
|
-
const rendered = elements.map((el) => {
|
|
1169
|
-
if (el.type === 'TSNamedTupleMember') {
|
|
1170
|
-
const inner = el['elementType'];
|
|
1171
|
-
return renderType(inner);
|
|
1172
|
-
}
|
|
1173
|
-
return renderType(el);
|
|
1174
|
-
});
|
|
1175
|
-
return `[${rendered.join(', ')}]`;
|
|
1176
|
-
}
|
|
1177
|
-
case 'TSUnionType': {
|
|
1178
|
-
const members = typeNode['types'] ?? [];
|
|
1179
|
-
return members.map(renderType).join(' | ');
|
|
1180
|
-
}
|
|
1181
|
-
case 'TSLiteralType': {
|
|
1182
|
-
const lit = typeNode['literal'];
|
|
1183
|
-
const v = lit?.['value'];
|
|
1184
|
-
if (typeof v === 'string')
|
|
1185
|
-
return `'${v}'`;
|
|
1186
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1187
|
-
return String(v);
|
|
1188
|
-
return 'unknown';
|
|
1189
|
-
}
|
|
1190
|
-
case 'TSTypeReference': {
|
|
1191
|
-
const tn = typeNode['typeName']?.['name'];
|
|
1192
|
-
const args = typeNode['typeArguments'];
|
|
1193
|
-
const base = tn ?? 'unknown';
|
|
1194
|
-
const params = args?.['params'] ?? null;
|
|
1195
|
-
if (params && params.length > 0) {
|
|
1196
|
-
return `${base}<${params.map(renderType).join(', ')}>`;
|
|
1197
|
-
}
|
|
1198
|
-
return base;
|
|
1199
|
-
}
|
|
1200
|
-
case 'TSFunctionType': {
|
|
1201
|
-
const params = (typeNode['params'] ?? []).map((p) => {
|
|
1202
|
-
const ann = p['typeAnnotation']?.['typeAnnotation'];
|
|
1203
|
-
const pname = p['name'] ?? 'arg';
|
|
1204
|
-
return `${pname}: ${renderType(ann)}`;
|
|
1205
|
-
});
|
|
1206
|
-
const ret = typeNode['returnType']?.['typeAnnotation'];
|
|
1207
|
-
return `(${params.join(', ')}) => ${renderType(ret)}`;
|
|
1208
|
-
}
|
|
1209
|
-
default:
|
|
1210
|
-
return 'unknown';
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
function extractJsdocText(comments) {
|
|
1214
|
-
if (!comments.length)
|
|
1215
|
-
return undefined;
|
|
1216
|
-
const last = comments[comments.length - 1];
|
|
1217
|
-
if (last.type !== 'Block')
|
|
1218
|
-
return undefined;
|
|
1219
|
-
// Strip leading * on each line, trim, join.
|
|
1220
|
-
const text = last.value
|
|
1221
|
-
.split('\n')
|
|
1222
|
-
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
|
|
1223
|
-
.filter(Boolean)
|
|
1224
|
-
.join(' ')
|
|
1225
|
-
.trim();
|
|
1226
|
-
return text || undefined;
|
|
1227
|
-
}
|
|
1228
|
-
// ---------------------------------------------------------------------------
|
|
1229
|
-
// Extraction: ObjectPattern destructure + type-members
|
|
1230
|
-
// ---------------------------------------------------------------------------
|
|
1231
|
-
function extractFromDestructure(propsCall, _ctx, typeMembers, warnings, additionalReasons) {
|
|
1232
|
-
const id = propsCall['id'];
|
|
1233
|
-
const properties = id['properties'] ?? [];
|
|
1234
|
-
const typeByName = new Map();
|
|
1235
|
-
if (typeMembers)
|
|
1236
|
-
for (const m of typeMembers)
|
|
1237
|
-
typeByName.set(m.name, m);
|
|
1238
|
-
const props = [];
|
|
1239
|
-
const snippetNames = new Set();
|
|
1240
|
-
const snippetSlots = [];
|
|
1241
|
-
const seenInDestructure = new Set();
|
|
1242
|
-
let dropsRest = false;
|
|
1243
|
-
for (const p of properties) {
|
|
1244
|
-
if (p.type === 'RestElement') {
|
|
1245
|
-
dropsRest = true;
|
|
1246
|
-
continue;
|
|
1247
|
-
}
|
|
1248
|
-
if (p.type !== 'Property')
|
|
1249
|
-
continue;
|
|
1250
|
-
const key = p['key'];
|
|
1251
|
-
const name = key?.['name'] ?? null;
|
|
1252
|
-
if (!name)
|
|
1253
|
-
continue;
|
|
1254
|
-
seenInDestructure.add(name);
|
|
1255
|
-
const value = p['value'];
|
|
1256
|
-
const hasDefault = value?.type === 'AssignmentPattern';
|
|
1257
|
-
const defaultValueRaw = hasDefault ? renderLiteral(value['right']) : undefined;
|
|
1258
|
-
const typeMember = typeByName.get(name);
|
|
1259
|
-
if (typeMember?.isSnippet) {
|
|
1260
|
-
snippetNames.add(name);
|
|
1261
|
-
const slot = {
|
|
1262
|
-
name,
|
|
1263
|
-
isDefault: name === 'children',
|
|
1264
|
-
...(typeMember.description ? { description: typeMember.description } : {}),
|
|
1265
|
-
};
|
|
1266
|
-
// Stash the author-facing type text so the run-level post-pass can
|
|
1267
|
-
// resolve $allowedComponents from `Snippet<[XProps]>`.
|
|
1268
|
-
// Prefer the author-facing `Snippet<[XProps]>` shape; the resolved
|
|
1269
|
-
// typeText may be an expanded call signature that loses the tuple form.
|
|
1270
|
-
const authorText = typeMember.declaredTypeText ?? typeMember.typeText;
|
|
1271
|
-
if (authorText)
|
|
1272
|
-
slot._rawTypeText = authorText;
|
|
1273
|
-
snippetSlots.push(slot);
|
|
1274
|
-
continue;
|
|
1275
|
-
}
|
|
1276
|
-
const required = typeMember ? !typeMember.optional && !hasDefault : !hasDefault;
|
|
1277
|
-
const propDef = {
|
|
1278
|
-
name,
|
|
1279
|
-
type: typeMember?.typeText ?? 'unknown',
|
|
1280
|
-
required,
|
|
1281
|
-
};
|
|
1282
|
-
if (defaultValueRaw !== undefined)
|
|
1283
|
-
propDef.defaultValue = defaultValueRaw;
|
|
1284
|
-
if (typeMember?.allowedValues)
|
|
1285
|
-
propDef.allowedValues = typeMember.allowedValues;
|
|
1286
|
-
if (typeMember?.description)
|
|
1287
|
-
propDef.description = typeMember.description;
|
|
1288
|
-
props.push(propDef);
|
|
1289
|
-
}
|
|
1290
|
-
// Note: members declared in the type but not destructured are intentionally NOT surfaced
|
|
1291
|
-
// here. With `let { foo, ...rest } = $props()`, only `foo` is bound by name; the rest
|
|
1292
|
-
// are collected into the rest binding and are not individually addressable. Authoring
|
|
1293
|
-
// contract = the destructure list. (Type-members-only path runs separately for the
|
|
1294
|
-
// `const props: Props = $props()` no-destructure case.)
|
|
1295
|
-
// Note: a `...rest` element in the destructure is intentionally NOT warned on.
|
|
1296
|
-
// Nearly every Svelte 5 component in real libraries uses rest-spread to pass
|
|
1297
|
-
// arbitrary HTML attributes through to the underlying element, so this would
|
|
1298
|
-
// fire on most components. The downstream pipeline already strips DOM/a11y
|
|
1299
|
-
// pass-through attributes globally (see `project_dsi-dom-prop-exclusion-decision`),
|
|
1300
|
-
// and the named props we DID extract from the destructure are the meaningful
|
|
1301
|
-
// authoring API. Tracking the rest spread isn't useful signal.
|
|
1302
|
-
void dropsRest;
|
|
1303
|
-
return {
|
|
1304
|
-
props: props.sort((a, b) => sortStable(a.name, b.name, propertyOrder(properties))),
|
|
1305
|
-
snippetNames,
|
|
1306
|
-
snippetSlots,
|
|
1307
|
-
warnings,
|
|
1308
|
-
...(additionalReasons && additionalReasons.length > 0 ? { additionalReasons } : {}),
|
|
1309
|
-
};
|
|
1310
|
-
}
|
|
1311
|
-
function extractFromTypeMembersOnly(typeMembers) {
|
|
1312
|
-
const props = [];
|
|
1313
|
-
const snippetNames = new Set();
|
|
1314
|
-
const snippetSlots = [];
|
|
1315
|
-
for (const m of typeMembers) {
|
|
1316
|
-
if (m.isSnippet) {
|
|
1317
|
-
snippetNames.add(m.name);
|
|
1318
|
-
const slot = {
|
|
1319
|
-
name: m.name,
|
|
1320
|
-
isDefault: m.name === 'children',
|
|
1321
|
-
...(m.description ? { description: m.description } : {}),
|
|
1322
|
-
};
|
|
1323
|
-
const authorText = m.declaredTypeText ?? m.typeText;
|
|
1324
|
-
if (authorText)
|
|
1325
|
-
slot._rawTypeText = authorText;
|
|
1326
|
-
snippetSlots.push(slot);
|
|
1327
|
-
continue;
|
|
1328
|
-
}
|
|
1329
|
-
const propDef = {
|
|
1330
|
-
name: m.name,
|
|
1331
|
-
type: m.typeText,
|
|
1332
|
-
required: !m.optional,
|
|
1333
|
-
};
|
|
1334
|
-
if (m.allowedValues)
|
|
1335
|
-
propDef.allowedValues = m.allowedValues;
|
|
1336
|
-
if (m.description)
|
|
1337
|
-
propDef.description = m.description;
|
|
1338
|
-
props.push(propDef);
|
|
1339
|
-
}
|
|
1340
|
-
return { props, snippetNames, snippetSlots, warnings: [] };
|
|
1341
|
-
}
|
|
1342
|
-
function propertyOrder(properties) {
|
|
1343
|
-
const m = new Map();
|
|
1344
|
-
let i = 0;
|
|
1345
|
-
for (const p of properties) {
|
|
1346
|
-
if (p.type !== 'Property')
|
|
1347
|
-
continue;
|
|
1348
|
-
const name = p['key']?.['name'] ?? null;
|
|
1349
|
-
if (name)
|
|
1350
|
-
m.set(name, i++);
|
|
1351
|
-
}
|
|
1352
|
-
return m;
|
|
1353
|
-
}
|
|
1354
|
-
function sortStable(a, b, order) {
|
|
1355
|
-
const ai = order.get(a) ?? Infinity;
|
|
1356
|
-
const bi = order.get(b) ?? Infinity;
|
|
1357
|
-
if (ai !== bi)
|
|
1358
|
-
return ai - bi;
|
|
1359
|
-
return a.localeCompare(b);
|
|
1360
|
-
}
|
|
1361
|
-
function renderLiteral(node) {
|
|
1362
|
-
if (!node)
|
|
1363
|
-
return undefined;
|
|
1364
|
-
if (node.type === 'Literal') {
|
|
1365
|
-
const v = node['value'];
|
|
1366
|
-
const raw = node['raw'];
|
|
1367
|
-
if (typeof v === 'string')
|
|
1368
|
-
return `'${v}'`;
|
|
1369
|
-
if (typeof raw === 'string')
|
|
1370
|
-
return raw;
|
|
1371
|
-
if (v === null)
|
|
1372
|
-
return 'null';
|
|
1373
|
-
return String(v);
|
|
1374
|
-
}
|
|
1375
|
-
if (node.type === 'Identifier')
|
|
1376
|
-
return node['name'] ?? undefined;
|
|
1377
|
-
if (node.type === 'ArrayExpression') {
|
|
1378
|
-
const els = node['elements'] ?? [];
|
|
1379
|
-
return `[${els.map((e) => renderLiteral(e) ?? '').join(', ')}]`;
|
|
1380
|
-
}
|
|
1381
|
-
if (node.type === 'ObjectExpression')
|
|
1382
|
-
return '{}';
|
|
1383
|
-
return undefined;
|
|
1384
|
-
}
|
|
1385
|
-
// ---------------------------------------------------------------------------
|
|
1386
|
-
// Imported Props resolution via ts-morph (cross-file)
|
|
1387
|
-
// ---------------------------------------------------------------------------
|
|
1388
|
-
async function resolveImportedTypeMembers(typeName, instance, filePath) {
|
|
1389
|
-
const body = instance['content']?.['body'];
|
|
1390
|
-
if (!body)
|
|
1391
|
-
return null;
|
|
1392
|
-
for (const stmt of body) {
|
|
1393
|
-
if (stmt.type !== 'ImportDeclaration')
|
|
1394
|
-
continue;
|
|
1395
|
-
const specifierValue = stmt['source']?.['value'];
|
|
1396
|
-
if (!specifierValue || !specifierValue.startsWith('.'))
|
|
1397
|
-
continue;
|
|
1398
|
-
const specifiers = stmt['specifiers'] ?? [];
|
|
1399
|
-
let importedExport = null;
|
|
1400
|
-
for (const spec of specifiers) {
|
|
1401
|
-
if (spec.type !== 'ImportSpecifier')
|
|
1402
|
-
continue;
|
|
1403
|
-
const localName = spec['local']?.['name'];
|
|
1404
|
-
if (localName === typeName) {
|
|
1405
|
-
importedExport = spec['imported']?.['name'] ?? null;
|
|
1406
|
-
break;
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
if (!importedExport)
|
|
1410
|
-
continue;
|
|
1411
|
-
const resolvedFile = resolveLocalScriptModule(filePath, specifierValue);
|
|
1412
|
-
if (!resolvedFile)
|
|
1413
|
-
continue;
|
|
1414
|
-
return readMembersFromExternalFile(resolvedFile, importedExport);
|
|
1415
|
-
}
|
|
1416
|
-
return null;
|
|
1417
|
-
}
|
|
1418
|
-
function resolveLocalScriptModule(importingFilePath, specifier) {
|
|
1419
|
-
const basePath = resolve(dirname(importingFilePath), specifier);
|
|
1420
|
-
const candidates = [
|
|
1421
|
-
basePath,
|
|
1422
|
-
`${basePath}.js`,
|
|
1423
|
-
`${basePath}.ts`,
|
|
1424
|
-
`${basePath}.mjs`,
|
|
1425
|
-
`${basePath}.cjs`,
|
|
1426
|
-
join(basePath, 'index.js'),
|
|
1427
|
-
join(basePath, 'index.ts'),
|
|
1428
|
-
join(basePath, 'index.mjs'),
|
|
1429
|
-
join(basePath, 'index.cjs'),
|
|
1430
|
-
];
|
|
1431
|
-
for (const c of candidates) {
|
|
1432
|
-
if (existsSync(c) && statSync(c).isFile())
|
|
1433
|
-
return c;
|
|
1434
|
-
}
|
|
1435
|
-
// Fallback: handle `.js` import that maps to a `.ts` source on disk (NodeNext convention)
|
|
1436
|
-
if (basePath.endsWith('.js')) {
|
|
1437
|
-
const tsPath = `${basePath.slice(0, -'.js'.length)}.ts`;
|
|
1438
|
-
if (existsSync(tsPath) && statSync(tsPath).isFile())
|
|
1439
|
-
return tsPath;
|
|
1440
|
-
}
|
|
1441
|
-
return null;
|
|
1442
|
-
}
|
|
1443
|
-
function readMembersFromExternalFile(filePath, exportName) {
|
|
1444
|
-
const project = new Project({
|
|
1445
|
-
compilerOptions: {
|
|
1446
|
-
strict: false,
|
|
1447
|
-
target: ScriptTarget.ESNext,
|
|
1448
|
-
module: ModuleKind.ESNext,
|
|
1449
|
-
allowJs: true,
|
|
1450
|
-
},
|
|
1451
|
-
useInMemoryFileSystem: false,
|
|
1452
|
-
skipAddingFilesFromTsConfig: true,
|
|
1453
|
-
});
|
|
1454
|
-
const sf = project.addSourceFileAtPathIfExists(filePath);
|
|
1455
|
-
if (!sf)
|
|
1456
|
-
return null;
|
|
1457
|
-
// Collect Snippet locals from the resolved file's own imports so that
|
|
1458
|
-
// aliased imports (`import { Snippet as LocalSnippet } from 'svelte'`) are
|
|
1459
|
-
// honored. Mirrors collectSnippetImportLocals() but adapted to ts-morph.
|
|
1460
|
-
const snippetLocals = collectSnippetLocalsFromSourceFile(sf);
|
|
1461
|
-
// Use getExportedDeclarations() so re-export chains (`export { ... } from './x'`,
|
|
1462
|
-
// `export * from './x'`, named or namespace) resolve transparently. ts-morph
|
|
1463
|
-
// follows them recursively and returns the underlying declaration.
|
|
1464
|
-
const exportedDeclarations = sf.getExportedDeclarations();
|
|
1465
|
-
const decls = exportedDeclarations.get(exportName);
|
|
1466
|
-
if (!decls || decls.length === 0)
|
|
1467
|
-
return null;
|
|
1468
|
-
for (const decl of decls) {
|
|
1469
|
-
if (Node.isInterfaceDeclaration(decl)) {
|
|
1470
|
-
// The declaration may live in a different source file than `sf` if the
|
|
1471
|
-
// export chain walked across files; pull snippet locals from that file
|
|
1472
|
-
// so aliased imports are recognized.
|
|
1473
|
-
const declSf = decl.getSourceFile();
|
|
1474
|
-
const declLocals = declSf === sf ? snippetLocals : collectSnippetLocalsFromSourceFile(declSf);
|
|
1475
|
-
return readInterfaceMembers(decl, declLocals);
|
|
1476
|
-
}
|
|
1477
|
-
if (Node.isTypeAliasDeclaration(decl)) {
|
|
1478
|
-
const typeNode = decl.getTypeNode();
|
|
1479
|
-
if (typeNode && Node.isTypeLiteral(typeNode)) {
|
|
1480
|
-
const declSf = decl.getSourceFile();
|
|
1481
|
-
const declLocals = declSf === sf ? snippetLocals : collectSnippetLocalsFromSourceFile(declSf);
|
|
1482
|
-
return readTypeLiteralMembers(typeNode, declLocals);
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
return null;
|
|
1487
|
-
}
|
|
1488
|
-
function collectSnippetLocalsFromSourceFile(sf) {
|
|
1489
|
-
const locals = new Set();
|
|
1490
|
-
for (const importDecl of sf.getImportDeclarations()) {
|
|
1491
|
-
if (importDecl.getModuleSpecifierValue() !== 'svelte')
|
|
1492
|
-
continue;
|
|
1493
|
-
for (const named of importDecl.getNamedImports()) {
|
|
1494
|
-
if (named.getName() === 'Snippet') {
|
|
1495
|
-
const aliasNode = named.getAliasNode();
|
|
1496
|
-
locals.add(aliasNode ? aliasNode.getText() : named.getName());
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
return locals;
|
|
1501
|
-
}
|
|
1502
|
-
function readInterfaceMembers(iface, snippetLocals) {
|
|
1503
|
-
return iface.getProperties().map((prop) => {
|
|
1504
|
-
const typeNode = prop.getTypeNode();
|
|
1505
|
-
const typeText = typeNode ? typeNode.getText() : prop.getType().getText(prop);
|
|
1506
|
-
const allowed = extractAllowedValuesFromText(typeText);
|
|
1507
|
-
const jsdocs = prop.getJsDocs();
|
|
1508
|
-
const description = jsdocs.length > 0 ? jsdocs[0].getDescription().trim() : undefined;
|
|
1509
|
-
return {
|
|
1510
|
-
name: prop.getName(),
|
|
1511
|
-
optional: prop.hasQuestionToken(),
|
|
1512
|
-
typeText,
|
|
1513
|
-
isSnippet: isSnippetTypeText(typeText, snippetLocals),
|
|
1514
|
-
...(allowed ? { allowedValues: allowed } : {}),
|
|
1515
|
-
...(description ? { description } : {}),
|
|
1516
|
-
line: prop.getStartLineNumber(),
|
|
1517
|
-
endLine: prop.getEndLineNumber(),
|
|
1518
|
-
};
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
function readTypeLiteralMembers(typeNode, snippetLocals) {
|
|
1522
|
-
return typeNode.getMembers().flatMap((m) => {
|
|
1523
|
-
if (!Node.isPropertySignature(m))
|
|
1524
|
-
return [];
|
|
1525
|
-
const tn = m.getTypeNode();
|
|
1526
|
-
const typeText = tn ? tn.getText() : 'unknown';
|
|
1527
|
-
const allowed = extractAllowedValuesFromText(typeText);
|
|
1528
|
-
const jsdocs = m.getJsDocs();
|
|
1529
|
-
const description = jsdocs.length > 0 ? jsdocs[0].getDescription().trim() : undefined;
|
|
1530
|
-
return [
|
|
1531
|
-
{
|
|
1532
|
-
name: m.getName(),
|
|
1533
|
-
optional: m.hasQuestionToken(),
|
|
1534
|
-
typeText,
|
|
1535
|
-
isSnippet: isSnippetTypeText(typeText, snippetLocals),
|
|
1536
|
-
...(allowed ? { allowedValues: allowed } : {}),
|
|
1537
|
-
...(description ? { description } : {}),
|
|
1538
|
-
line: m.getStartLineNumber(),
|
|
1539
|
-
endLine: m.getEndLineNumber(),
|
|
1540
|
-
},
|
|
1541
|
-
];
|
|
1542
|
-
});
|
|
1543
|
-
}
|
|
1544
|
-
function extractAllowedValuesFromText(typeText) {
|
|
1545
|
-
// Quick parse of `'a' | 'b' | 'c'`.
|
|
1546
|
-
const parts = typeText
|
|
1547
|
-
.split('|')
|
|
1548
|
-
.map((p) => p.trim())
|
|
1549
|
-
.filter(Boolean);
|
|
1550
|
-
if (parts.length < 2)
|
|
1551
|
-
return undefined;
|
|
1552
|
-
const out = [];
|
|
1553
|
-
for (const p of parts) {
|
|
1554
|
-
const m = p.match(/^'([^']*)'$/) ?? p.match(/^"([^"]*)"$/);
|
|
1555
|
-
if (!m)
|
|
1556
|
-
return undefined;
|
|
1557
|
-
out.push(m[1]);
|
|
1558
|
-
}
|
|
1559
|
-
return out.length > 0 ? out : undefined;
|
|
1560
|
-
}
|
|
1561
|
-
// ---------------------------------------------------------------------------
|
|
1562
|
-
// Slot extraction (template <slot> + Snippet props merge)
|
|
1563
|
-
// ---------------------------------------------------------------------------
|
|
1564
|
-
function extractTemplateSlots(fragment) {
|
|
1565
|
-
const slots = [];
|
|
1566
|
-
const seen = new Set();
|
|
1567
|
-
walk(fragment);
|
|
1568
|
-
return slots;
|
|
1569
|
-
function walk(node) {
|
|
1570
|
-
if (!node)
|
|
1571
|
-
return;
|
|
1572
|
-
if (node.type === 'SlotElement') {
|
|
1573
|
-
const name = readSlotName(node);
|
|
1574
|
-
if (!seen.has(name)) {
|
|
1575
|
-
seen.add(name);
|
|
1576
|
-
slots.push({ name, isDefault: name === 'default' });
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
const children = node['nodes'] ?? node['children'] ?? [];
|
|
1580
|
-
for (const c of children)
|
|
1581
|
-
walk(c);
|
|
1582
|
-
const fragChild = node['fragment'];
|
|
1583
|
-
if (fragChild)
|
|
1584
|
-
walk(fragChild);
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
function readSlotName(slotEl) {
|
|
1588
|
-
const attrs = slotEl['attributes'] ?? [];
|
|
1589
|
-
for (const attr of attrs) {
|
|
1590
|
-
if (attr['name'] !== 'name')
|
|
1591
|
-
continue;
|
|
1592
|
-
const value = attr['value'];
|
|
1593
|
-
if (Array.isArray(value)) {
|
|
1594
|
-
for (const v of value) {
|
|
1595
|
-
if (v.type === 'Text' && typeof v['data'] === 'string')
|
|
1596
|
-
return v['data'];
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
return 'default';
|
|
1601
|
-
}
|
|
1602
|
-
function mergeSlots(fromSnippetProps, fromTemplate) {
|
|
1603
|
-
if (fromSnippetProps.length === 0)
|
|
1604
|
-
return { slots: fromTemplate, mixedWarning: false };
|
|
1605
|
-
if (fromTemplate.length === 0)
|
|
1606
|
-
return { slots: fromSnippetProps, mixedWarning: false };
|
|
1607
|
-
const byName = new Map();
|
|
1608
|
-
for (const s of fromSnippetProps)
|
|
1609
|
-
byName.set(s.name, s);
|
|
1610
|
-
const snippetHasDefault = fromSnippetProps.some((s) => s.isDefault);
|
|
1611
|
-
let mixed = false;
|
|
1612
|
-
for (const s of fromTemplate) {
|
|
1613
|
-
// Default <slot/> in template is the same surface as a `children: Snippet` prop.
|
|
1614
|
-
// Prefer the Snippet entry when both are present.
|
|
1615
|
-
if (s.isDefault && snippetHasDefault) {
|
|
1616
|
-
mixed = true;
|
|
1617
|
-
continue;
|
|
1618
|
-
}
|
|
1619
|
-
if (byName.has(s.name)) {
|
|
1620
|
-
mixed = true;
|
|
1621
|
-
continue;
|
|
1622
|
-
}
|
|
1623
|
-
byName.set(s.name, s);
|
|
1624
|
-
}
|
|
1625
|
-
return { slots: [...byName.values()], mixedWarning: mixed };
|
|
1626
|
-
}
|