@contentful/experience-design-system-cli 2.11.4-dev-build-0373000.0 → 2.11.4-dev-build-8f1adf3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.11.4-dev-build-0373000.0",
3
+ "version": "2.11.4-dev-build-8f1adf3.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "react": "^18.3.1",
45
45
  "react-devtools-core": "^4.19.1",
46
46
  "react-dom": "^18.3.1",
47
+ "svelte": "^5.56.4",
47
48
  "ts-morph": "^27.0.2",
48
49
  "typescript": "^5.9.3"
49
50
  },
@@ -13,7 +13,7 @@ import { computeExtractionScore, deriveNeedsReview } from './extract/scoring.js'
13
13
  import { describeReviewReasons, inspectComponentSource } from './extract/source-inspection.js';
14
14
  import { validateExtractedComponents } from './extract/validate.js';
15
15
  import { buildAnalyzeViewRows } from './build-analyze-view-rows.js';
16
- const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
16
+ const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.svelte', '.ts', '.tsx', '.vue']);
17
17
  const IGNORED_DIRECTORY_NAMES = new Set([
18
18
  '.git',
19
19
  '.next',
@@ -4,6 +4,7 @@ import { extractVueTsxComponents } from './vue-tsx.js';
4
4
  import { extractVueComponents } from './vue.js';
5
5
  import { extractAstroComponents } from './astro.js';
6
6
  import { extractWebComponentDefinitions } from './web-components.js';
7
+ import { extractSvelteComponents } from './svelte.js';
7
8
  const extractors = [
8
9
  {
9
10
  name: 'stencil',
@@ -35,6 +36,21 @@ const extractors = [
35
36
  fileFilter: (f) => /\.[jt]s$/.test(f) && !/\.[jt]sx$/.test(f) && !f.endsWith('.d.ts'),
36
37
  extract: extractWebComponentDefinitions,
37
38
  },
39
+ {
40
+ name: 'svelte',
41
+ fileFilter: (f) => {
42
+ if (!f.endsWith('.svelte'))
43
+ return false;
44
+ // SvelteKit route conventions: +page.svelte, +layout.svelte, +error.svelte
45
+ // are framework-managed entrypoints, not authorable design-system components.
46
+ // Mirrors how Next.js page.tsx / layout.tsx are filtered in pre-classify.
47
+ const filename = f.replace(/\\/g, '/').split('/').pop() ?? '';
48
+ if (/^\+(page|layout|error)\.svelte$/.test(filename))
49
+ return false;
50
+ return true;
51
+ },
52
+ extract: extractSvelteComponents,
53
+ },
38
54
  ];
39
55
  function getPathPreferenceScore(filePath) {
40
56
  const normalized = filePath.replace(/\\/g, '/');
@@ -0,0 +1,5 @@
1
+ import type { ComponentExtractionResult } from '../../types.js';
2
+ export declare function extractSvelteComponents(filePaths: string[], onProgress?: (p: {
3
+ filesProcessed: number;
4
+ componentsFound: number;
5
+ }) => void): Promise<ComponentExtractionResult>;
@@ -0,0 +1,913 @@
1
+ import { basename, dirname, resolve, join } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import { parse as parseSvelte } from 'svelte/compiler';
6
+ import { Project, Node } from 'ts-morph';
7
+ import { computeExtractionScore, deriveNeedsReview } from './scoring.js';
8
+ const SVELTE_EXTRACT_CONCURRENCY = Number(process.env['EDS_EXTRACT_CONCURRENCY'] ?? 0) || os.cpus().length;
9
+ export async function extractSvelteComponents(filePaths, onProgress) {
10
+ const svelteFiles = filePaths.filter((f) => f.endsWith('.svelte'));
11
+ if (svelteFiles.length === 0)
12
+ return { components: [], warnings: [] };
13
+ const warnings = [];
14
+ const components = [];
15
+ let filesProcessed = 0;
16
+ let componentsFound = 0;
17
+ const queue = [...svelteFiles];
18
+ async function worker() {
19
+ while (queue.length > 0) {
20
+ const filePath = queue.shift();
21
+ if (!filePath)
22
+ break;
23
+ try {
24
+ const source = await readFile(filePath, 'utf-8');
25
+ const { component, warnings: fileWarnings } = await extractFromSvelteFile(filePath, source);
26
+ warnings.push(...fileWarnings);
27
+ if (component) {
28
+ components.push(component);
29
+ componentsFound++;
30
+ }
31
+ }
32
+ catch (e) {
33
+ warnings.push(`Failed to extract from ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
34
+ }
35
+ filesProcessed++;
36
+ onProgress?.({ filesProcessed, componentsFound });
37
+ }
38
+ }
39
+ await Promise.all(Array.from({ length: Math.min(SVELTE_EXTRACT_CONCURRENCY, svelteFiles.length) }, worker));
40
+ return {
41
+ components: components.sort((a, b) => a.name.localeCompare(b.name)),
42
+ warnings,
43
+ };
44
+ }
45
+ async function extractFromSvelteFile(filePath, source) {
46
+ const warnings = [];
47
+ let ast;
48
+ try {
49
+ ast = parseSvelte(source, { modern: true });
50
+ }
51
+ catch (e) {
52
+ return {
53
+ component: null,
54
+ warnings: [`Parse error in ${filePath}: ${e instanceof Error ? e.message : String(e)}`],
55
+ };
56
+ }
57
+ const name = getSvelteComponentName(filePath);
58
+ const instance = ast['instance'];
59
+ const moduleScript = ast['module'];
60
+ const fragment = ast['fragment'];
61
+ // Detect Svelte 4 export-let syntax — currently unsupported.
62
+ if (instance && hasV4ExportLetProps(instance)) {
63
+ warnings.push(`Svelte 4 export let syntax not yet supported (file: ${filePath}); see INTEG-4267 for v5-only scope and follow-up`);
64
+ return { component: null, warnings };
65
+ }
66
+ // Find the $props() call (Svelte 5 runes).
67
+ const propsCall = instance ? findPropsCall(instance) : null;
68
+ // Snippet import map: localName -> true if it refers to `Snippet` from 'svelte'.
69
+ const snippetLocals = instance ? collectSnippetImportLocals(instance) : new Set();
70
+ // --- Props extraction ---
71
+ let props = [];
72
+ let propNamesTreatedAsSnippet = new Set();
73
+ let snippetSlotsFromProps = [];
74
+ if (propsCall) {
75
+ const result = await extractPropsFromCall({
76
+ propsCall,
77
+ instance: instance,
78
+ moduleScript,
79
+ filePath,
80
+ source,
81
+ snippetLocals,
82
+ });
83
+ props = result.props;
84
+ propNamesTreatedAsSnippet = result.snippetNames;
85
+ snippetSlotsFromProps = result.snippetSlots;
86
+ warnings.push(...result.warnings);
87
+ }
88
+ else if (instance) {
89
+ // No $props() call. We've already ruled out v4 above; this means $props was used
90
+ // but couldn't be located, or the script has no rune-style props at all.
91
+ // Component still extracted (slots from template may exist).
92
+ }
93
+ else {
94
+ // No script block at all — nothing to extract on the props side.
95
+ warnings.push(`${filePath} has no instance script block; no props extracted`);
96
+ }
97
+ // --- Slot extraction ---
98
+ const templateSlots = fragment ? extractTemplateSlots(fragment) : [];
99
+ const { slots, mixedWarning } = mergeSlots(snippetSlotsFromProps, templateSlots);
100
+ if (mixedWarning) {
101
+ warnings.push(`${filePath}: mixed Snippet and <slot> usage detected; preferring Snippet entries`);
102
+ }
103
+ const component = {
104
+ name,
105
+ source: filePath,
106
+ framework: 'svelte',
107
+ props,
108
+ slots,
109
+ };
110
+ // Score & flag review.
111
+ const score = computeExtractionScore(component);
112
+ component.extractionConfidence = score.confidence;
113
+ component.reviewReasons = score.reasons;
114
+ component.needsReview = deriveNeedsReview(score.confidence);
115
+ // Validation issues (EMPTY_COMPONENT_NAME / EMPTY_PROP_NAME / PROP_SLOT_NAME_COLLISION /
116
+ // DUPLICATE_COMPONENT_NAME / EMPTY_COMPONENT / EMPTY_SLOT_NAME) are populated
117
+ // centrally by validateExtractedComponents() in analyze/command.ts after all
118
+ // extractors return — same convention as React, Vue, Astro, Stencil, and
119
+ // web-components. No per-extractor work needed here.
120
+ void propNamesTreatedAsSnippet; // currently informational; reserved for future validation
121
+ return { component, warnings };
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Component name
125
+ // ---------------------------------------------------------------------------
126
+ function getSvelteComponentName(filePath) {
127
+ const file = basename(filePath, '.svelte');
128
+ // index.svelte → use parent directory name (mirrors index.vue behavior).
129
+ if (file === 'index') {
130
+ const parent = basename(dirname(filePath));
131
+ return toPascalCase(parent);
132
+ }
133
+ return toPascalCase(file);
134
+ }
135
+ function toPascalCase(s) {
136
+ if (!s)
137
+ return s;
138
+ // Already PascalCase? leave it.
139
+ if (/^[A-Z]/.test(s) && !s.includes('-') && !s.includes('_'))
140
+ return s;
141
+ return s
142
+ .split(/[-_]+/)
143
+ .filter(Boolean)
144
+ .map((part) => part[0].toUpperCase() + part.slice(1))
145
+ .join('');
146
+ }
147
+ // ---------------------------------------------------------------------------
148
+ // Detection helpers
149
+ // ---------------------------------------------------------------------------
150
+ function hasV4ExportLetProps(instance) {
151
+ const body = instance['content']?.['body'];
152
+ if (!body)
153
+ return false;
154
+ return body.some((stmt) => {
155
+ if (stmt.type !== 'ExportNamedDeclaration')
156
+ return false;
157
+ const decl = stmt['declaration'];
158
+ return decl?.type === 'VariableDeclaration' && decl['kind'] === 'let';
159
+ });
160
+ }
161
+ function findPropsCall(instance) {
162
+ const body = instance['content']?.['body'];
163
+ if (!body)
164
+ return null;
165
+ let found = null;
166
+ for (const stmt of body) {
167
+ if (stmt.type !== 'VariableDeclaration')
168
+ continue;
169
+ const decls = stmt['declarations'];
170
+ if (!decls)
171
+ continue;
172
+ for (const d of decls) {
173
+ const init = d['init'];
174
+ if (!init || init.type !== 'CallExpression')
175
+ continue;
176
+ const callee = init['callee'];
177
+ if (callee?.type === 'Identifier' && callee['name'] === '$props') {
178
+ if (found === null)
179
+ found = d;
180
+ // Multiple $props() calls — first wins; we'll warn from the caller.
181
+ }
182
+ }
183
+ }
184
+ return found;
185
+ }
186
+ function collectSnippetImportLocals(instance) {
187
+ const locals = new Set();
188
+ const body = instance['content']?.['body'];
189
+ if (!body)
190
+ return locals;
191
+ for (const stmt of body) {
192
+ if (stmt.type !== 'ImportDeclaration')
193
+ continue;
194
+ const sourceNode = stmt['source'];
195
+ if (sourceNode?.['value'] !== 'svelte')
196
+ continue;
197
+ const specs = stmt['specifiers'];
198
+ if (!specs)
199
+ continue;
200
+ for (const spec of specs) {
201
+ if (spec.type !== 'ImportSpecifier')
202
+ continue;
203
+ const imported = spec['imported'];
204
+ const local = spec['local'];
205
+ if (imported?.['name'] === 'Snippet' && typeof local?.['name'] === 'string') {
206
+ locals.add(local['name']);
207
+ }
208
+ }
209
+ }
210
+ return locals;
211
+ }
212
+ async function extractPropsFromCall(ctx) {
213
+ const warnings = [];
214
+ const propsCall = ctx.propsCall;
215
+ const id = propsCall['id'];
216
+ const idType = id?.type;
217
+ // Decode the type annotation (the right side of `: Props` / `: { ... }`).
218
+ const annotation = id?.['typeAnnotation']?.['typeAnnotation'];
219
+ // Resolve the type members once (works for both ObjectPattern and Identifier id forms).
220
+ const typeMembers = annotation
221
+ ? await resolveTypeMembers(annotation, ctx.instance, ctx.moduleScript, ctx.filePath, ctx.source)
222
+ : null;
223
+ if (idType === 'ObjectPattern') {
224
+ return extractFromDestructure(ctx.propsCall, ctx, typeMembers, warnings);
225
+ }
226
+ if (idType === 'Identifier') {
227
+ // const props: Props = $props(); — no destructure, no defaults, no per-name binding.
228
+ if (typeMembers) {
229
+ return extractFromTypeMembersOnly(typeMembers, ctx.snippetLocals, warnings);
230
+ }
231
+ warnings.push(`${ctx.filePath}: $props() called without destructuring; cannot extract individual props`);
232
+ return { props: [], snippetNames: new Set(), snippetSlots: [], warnings };
233
+ }
234
+ warnings.push(`${ctx.filePath}: unrecognized $props() binding pattern (${idType})`);
235
+ return { props: [], snippetNames: new Set(), snippetSlots: [], warnings };
236
+ }
237
+ async function resolveTypeMembers(annotation, instance, moduleScript, filePath, source) {
238
+ // Snippet imports may live in either script block; collect from both.
239
+ const snippetLocals = mergeSets(collectSnippetImportLocals(instance), moduleScript ? collectSnippetImportLocals(moduleScript) : new Set());
240
+ // Fast path 1: inline type literal with no extends/intersection. The AST already
241
+ // gives us member-level optional/type/JSDoc; no ts-morph needed.
242
+ if (annotation.type === 'TSTypeLiteral') {
243
+ return readMembersFromTypeLiteral(annotation, snippetLocals);
244
+ }
245
+ // For named refs: try the AST fast path first (inline interface or type literal alias
246
+ // with no heritage clauses). If that returns >0 members AND the source declaration has
247
+ // no extends, we trust it. Otherwise fall back to ts-morph type-checker resolution to
248
+ // pick up extends / Omit / intersection / Partial / generics.
249
+ if (annotation.type === 'TSTypeReference') {
250
+ const refName = annotation['typeName']?.['name'] ?? null;
251
+ if (refName) {
252
+ const local = findLocalTypeDeclaration(instance, refName, moduleScript);
253
+ if (local) {
254
+ const fastPathMembers = readMembersFromInterfaceOrAlias(local, snippetLocals);
255
+ if (fastPathMembers.length > 0 && !declarationHasHeritage(local)) {
256
+ return fastPathMembers;
257
+ }
258
+ // Heritage or empty body — fall through to ts-morph resolution.
259
+ }
260
+ else {
261
+ // No local declaration — try import resolution via ts-morph.
262
+ const imported = (await resolveImportedTypeMembers(refName, instance, filePath)) ??
263
+ (moduleScript ? await resolveImportedTypeMembers(refName, moduleScript, filePath) : null);
264
+ if (imported)
265
+ return imported;
266
+ }
267
+ }
268
+ }
269
+ // Slow path: ts-morph type-checker resolution. Materializes the annotation text as
270
+ // `type __SveltePropsT__ = <annotation>;` inside an in-memory file containing the
271
+ // combined script bodies, then reads .getType().getProperties(). The TS checker
272
+ // resolves extends, &, Omit, Pick, Partial, generics — anything TS itself resolves.
273
+ return resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals);
274
+ }
275
+ function declarationHasHeritage(decl) {
276
+ if (decl.type === 'TSInterfaceDeclaration') {
277
+ const ext = decl['extends'];
278
+ return Array.isArray(ext) && ext.length > 0;
279
+ }
280
+ return false;
281
+ }
282
+ async function resolveViaTypeChecker(annotation, instance, moduleScript, filePath, source, snippetLocals) {
283
+ const annotationText = sliceSource(source, annotation);
284
+ if (!annotationText)
285
+ return null;
286
+ // Reconstruct the combined script content: module-script first, then instance.
287
+ // Both bodies share scope in the synthetic file, which is enough for TS to resolve
288
+ // local interfaces, type aliases, imports, and heritage clauses across them.
289
+ const moduleText = sliceScriptContent(source, moduleScript);
290
+ const instanceText = sliceScriptContent(source, instance);
291
+ const synthetic = [moduleText, instanceText, `type __SveltePropsT__ = ${annotationText};`].filter(Boolean).join('\n');
292
+ const project = new Project({
293
+ compilerOptions: { strict: false, target: 99, module: 99, allowJs: true, jsx: 1 },
294
+ useInMemoryFileSystem: false,
295
+ skipAddingFilesFromTsConfig: true,
296
+ });
297
+ // Place the synthetic file alongside the .svelte so relative imports resolve.
298
+ const syntheticPath = `${filePath}.__svelte-props__.ts`;
299
+ let sf;
300
+ try {
301
+ sf = project.createSourceFile(syntheticPath, synthetic, { overwrite: true });
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ const alias = sf.getTypeAlias('__SveltePropsT__');
307
+ if (!alias)
308
+ return null;
309
+ const type = alias.getType();
310
+ const apparent = type.getApparentType();
311
+ const properties = apparent.getProperties();
312
+ if (properties.length === 0)
313
+ return null;
314
+ const members = [];
315
+ for (const symbol of properties) {
316
+ const name = symbol.getName();
317
+ const declaration = symbol.getValueDeclaration() ?? symbol.getDeclarations()[0];
318
+ if (!declaration)
319
+ continue;
320
+ const propType = symbol.getTypeAtLocation(declaration);
321
+ let typeText = propType.getText(declaration);
322
+ // Strip ` | undefined` for clean output on optional props.
323
+ const optional = symbol.isOptional();
324
+ if (optional) {
325
+ typeText = typeText.replace(/\s*\|\s*undefined$/, '').replace(/^undefined\s*\|\s*/, '');
326
+ }
327
+ const allowed = extractAllowedValuesFromType(propType);
328
+ const description = readJsDocFromDeclaration(declaration);
329
+ const isSnippet = isSnippetTypeText(typeText, snippetLocals);
330
+ members.push({
331
+ name,
332
+ optional,
333
+ typeText,
334
+ isSnippet,
335
+ ...(allowed ? { allowedValues: allowed } : {}),
336
+ ...(description ? { description } : {}),
337
+ // line/endLine: prefer AST-derived location when the source declaration is in our
338
+ // svelte file. ts-morph's synthetic location refers to the synthetic file and
339
+ // isn't useful for the user.
340
+ });
341
+ }
342
+ return members;
343
+ }
344
+ function sliceSource(source, node) {
345
+ const start = node['start'] ?? null;
346
+ const end = node['end'] ?? null;
347
+ if (start == null || end == null)
348
+ return null;
349
+ return source.slice(start, end);
350
+ }
351
+ function sliceScriptContent(source, script) {
352
+ if (!script)
353
+ return null;
354
+ const content = script['content'];
355
+ if (!content)
356
+ return null;
357
+ return sliceSource(source, content);
358
+ }
359
+ function extractAllowedValuesFromType(type) {
360
+ if (!type.isUnion())
361
+ return undefined;
362
+ const out = [];
363
+ for (const t of type.getUnionTypes()) {
364
+ if (!t.isStringLiteral())
365
+ return undefined;
366
+ out.push(t.getLiteralValueOrThrow());
367
+ }
368
+ return out.length > 0 ? out : undefined;
369
+ }
370
+ function readJsDocFromDeclaration(decl) {
371
+ if (Node.isPropertySignature(decl) || Node.isInterfaceDeclaration(decl) || Node.isTypeAliasDeclaration(decl)) {
372
+ const jsdocs = decl.getJsDocs();
373
+ if (jsdocs.length > 0)
374
+ return jsdocs[0].getDescription().trim() || undefined;
375
+ }
376
+ return undefined;
377
+ }
378
+ function isSnippetTypeText(typeText, snippetLocals) {
379
+ // Direct match against the local Snippet name (handles aliasing).
380
+ for (const local of snippetLocals) {
381
+ if (typeText === local || typeText.startsWith(`${local}<`))
382
+ return true;
383
+ }
384
+ // Defensive fallback: TS may surface the canonical `Snippet` from the import.
385
+ return typeText === 'Snippet' || typeText.startsWith('Snippet<');
386
+ }
387
+ function mergeSets(a, b) {
388
+ const out = new Set(a);
389
+ for (const v of b)
390
+ out.add(v);
391
+ return out;
392
+ }
393
+ function findLocalTypeDeclaration(instance, name, module) {
394
+ // Skeleton-svelte and similar libraries declare the Props interface in
395
+ // <script lang="ts" module> and consume it in the regular <script>. Look
396
+ // in both bodies. Type declarations may also be wrapped in
397
+ // `export { ... }` statements (ExportNamedDeclaration with .declaration).
398
+ for (const script of [instance, module]) {
399
+ const body = script?.['content']?.['body'];
400
+ if (!body)
401
+ continue;
402
+ for (const stmt of body) {
403
+ const decl = stmt.type === 'ExportNamedDeclaration' ? (stmt['declaration'] ?? stmt) : stmt;
404
+ if (decl.type === 'TSInterfaceDeclaration' || decl.type === 'TSTypeAliasDeclaration') {
405
+ const id = decl['id'];
406
+ if (id?.['name'] === name)
407
+ return decl;
408
+ }
409
+ }
410
+ }
411
+ return null;
412
+ }
413
+ function readMembersFromInterfaceOrAlias(decl, snippetLocals) {
414
+ if (decl.type === 'TSInterfaceDeclaration') {
415
+ const body = decl['body']?.['body'];
416
+ if (!body)
417
+ return [];
418
+ return body.map((m) => readPropertySignature(m, snippetLocals)).filter((m) => !!m);
419
+ }
420
+ if (decl.type === 'TSTypeAliasDeclaration') {
421
+ const ann = decl['typeAnnotation'];
422
+ if (ann?.type === 'TSTypeLiteral')
423
+ return readMembersFromTypeLiteral(ann, snippetLocals);
424
+ }
425
+ return [];
426
+ }
427
+ function readMembersFromTypeLiteral(literal, snippetLocals) {
428
+ const members = literal['members'];
429
+ if (!members)
430
+ return [];
431
+ return members.map((m) => readPropertySignature(m, snippetLocals)).filter((m) => !!m);
432
+ }
433
+ function readPropertySignature(member, snippetLocals) {
434
+ if (member.type !== 'TSPropertySignature')
435
+ return null;
436
+ const key = member['key'];
437
+ const name = key?.['name'] ?? null;
438
+ if (!name)
439
+ return null;
440
+ const optional = !!member['optional'];
441
+ const typeNode = member['typeAnnotation']?.['typeAnnotation'];
442
+ const typeText = renderType(typeNode);
443
+ const isSnippet = isSnippetType(typeNode, snippetLocals);
444
+ const allowedValues = collectStringLiteralUnion(typeNode);
445
+ // JSDoc / comments — `leadingComments` may be on the member or on the parent.
446
+ const leading = member['leadingComments'] ?? [];
447
+ const description = extractJsdocText(leading);
448
+ return {
449
+ name,
450
+ optional,
451
+ typeText,
452
+ isSnippet,
453
+ allowedValues,
454
+ description,
455
+ line: member.loc?.start?.line,
456
+ endLine: member.loc?.end?.line,
457
+ };
458
+ }
459
+ function isSnippetType(typeNode, snippetLocals) {
460
+ if (!typeNode)
461
+ return false;
462
+ if (typeNode.type !== 'TSTypeReference')
463
+ return false;
464
+ const tn = typeNode['typeName']?.['name'];
465
+ if (!tn)
466
+ return false;
467
+ return snippetLocals.has(tn);
468
+ }
469
+ function collectStringLiteralUnion(typeNode) {
470
+ if (!typeNode)
471
+ return undefined;
472
+ if (typeNode.type !== 'TSUnionType')
473
+ return undefined;
474
+ const members = typeNode['types'];
475
+ if (!members)
476
+ return undefined;
477
+ const out = [];
478
+ for (const m of members) {
479
+ if (m.type !== 'TSLiteralType')
480
+ return undefined; // not a pure literal union
481
+ const lit = m['literal'];
482
+ const v = lit?.['value'];
483
+ if (typeof v !== 'string')
484
+ return undefined;
485
+ out.push(v);
486
+ }
487
+ return out.length > 0 ? out : undefined;
488
+ }
489
+ function renderType(typeNode) {
490
+ if (!typeNode)
491
+ return 'unknown';
492
+ switch (typeNode.type) {
493
+ case 'TSStringKeyword':
494
+ return 'string';
495
+ case 'TSNumberKeyword':
496
+ return 'number';
497
+ case 'TSBooleanKeyword':
498
+ return 'boolean';
499
+ case 'TSAnyKeyword':
500
+ return 'any';
501
+ case 'TSUnknownKeyword':
502
+ return 'unknown';
503
+ case 'TSNullKeyword':
504
+ return 'null';
505
+ case 'TSUndefinedKeyword':
506
+ return 'undefined';
507
+ case 'TSVoidKeyword':
508
+ return 'void';
509
+ case 'TSArrayType': {
510
+ return `${renderType(typeNode['elementType'])}[]`;
511
+ }
512
+ case 'TSUnionType': {
513
+ const members = typeNode['types'] ?? [];
514
+ return members.map(renderType).join(' | ');
515
+ }
516
+ case 'TSLiteralType': {
517
+ const lit = typeNode['literal'];
518
+ const v = lit?.['value'];
519
+ if (typeof v === 'string')
520
+ return `'${v}'`;
521
+ if (typeof v === 'number' || typeof v === 'boolean')
522
+ return String(v);
523
+ return 'unknown';
524
+ }
525
+ case 'TSTypeReference': {
526
+ const tn = typeNode['typeName']?.['name'];
527
+ const args = typeNode['typeArguments'];
528
+ const base = tn ?? 'unknown';
529
+ const params = args?.['params'] ?? null;
530
+ if (params && params.length > 0) {
531
+ return `${base}<${params.map(renderType).join(', ')}>`;
532
+ }
533
+ return base;
534
+ }
535
+ case 'TSFunctionType': {
536
+ const params = (typeNode['params'] ?? []).map((p) => {
537
+ const ann = p['typeAnnotation']?.['typeAnnotation'];
538
+ const pname = p['name'] ?? 'arg';
539
+ return `${pname}: ${renderType(ann)}`;
540
+ });
541
+ const ret = typeNode['returnType']?.['typeAnnotation'];
542
+ return `(${params.join(', ')}) => ${renderType(ret)}`;
543
+ }
544
+ default:
545
+ return 'unknown';
546
+ }
547
+ }
548
+ function extractJsdocText(comments) {
549
+ if (!comments.length)
550
+ return undefined;
551
+ const last = comments[comments.length - 1];
552
+ if (last.type !== 'Block')
553
+ return undefined;
554
+ // Strip leading * on each line, trim, join.
555
+ const text = last.value
556
+ .split('\n')
557
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
558
+ .filter(Boolean)
559
+ .join(' ')
560
+ .trim();
561
+ return text || undefined;
562
+ }
563
+ // ---------------------------------------------------------------------------
564
+ // Extraction: ObjectPattern destructure + type-members
565
+ // ---------------------------------------------------------------------------
566
+ function extractFromDestructure(propsCall, ctx, typeMembers, warnings) {
567
+ const id = propsCall['id'];
568
+ const properties = id['properties'] ?? [];
569
+ const typeByName = new Map();
570
+ if (typeMembers)
571
+ for (const m of typeMembers)
572
+ typeByName.set(m.name, m);
573
+ const props = [];
574
+ const snippetNames = new Set();
575
+ const snippetSlots = [];
576
+ const seenInDestructure = new Set();
577
+ let dropsRest = false;
578
+ for (const p of properties) {
579
+ if (p.type === 'RestElement') {
580
+ dropsRest = true;
581
+ continue;
582
+ }
583
+ if (p.type !== 'Property')
584
+ continue;
585
+ const key = p['key'];
586
+ const name = key?.['name'] ?? null;
587
+ if (!name)
588
+ continue;
589
+ seenInDestructure.add(name);
590
+ const value = p['value'];
591
+ const hasDefault = value?.type === 'AssignmentPattern';
592
+ const defaultValueRaw = hasDefault ? renderLiteral(value['right']) : undefined;
593
+ const typeMember = typeByName.get(name);
594
+ if (typeMember?.isSnippet) {
595
+ snippetNames.add(name);
596
+ snippetSlots.push({
597
+ name,
598
+ isDefault: name === 'children',
599
+ ...(typeMember.description ? { description: typeMember.description } : {}),
600
+ });
601
+ continue;
602
+ }
603
+ const required = typeMember ? !typeMember.optional && !hasDefault : !hasDefault;
604
+ const propDef = {
605
+ name,
606
+ type: typeMember?.typeText ?? 'unknown',
607
+ required,
608
+ };
609
+ if (defaultValueRaw !== undefined)
610
+ propDef.defaultValue = defaultValueRaw;
611
+ if (typeMember?.allowedValues)
612
+ propDef.allowedValues = typeMember.allowedValues;
613
+ if (typeMember?.description)
614
+ propDef.description = typeMember.description;
615
+ props.push(propDef);
616
+ }
617
+ // Note: members declared in the type but not destructured are intentionally NOT surfaced
618
+ // here. With `let { foo, ...rest } = $props()`, only `foo` is bound by name; the rest
619
+ // are collected into the rest binding and are not individually addressable. Authoring
620
+ // contract = the destructure list. (Type-members-only path runs separately for the
621
+ // `const props: Props = $props()` no-destructure case.)
622
+ if (dropsRest) {
623
+ warnings.push(`${ctx.filePath}: rest element in $props() destructure dropped (cannot enumerate)`);
624
+ }
625
+ return {
626
+ props: props.sort((a, b) => sortStable(a.name, b.name, propertyOrder(properties))),
627
+ snippetNames,
628
+ snippetSlots,
629
+ warnings,
630
+ };
631
+ }
632
+ function extractFromTypeMembersOnly(typeMembers, _snippetLocals, _warnings) {
633
+ const props = [];
634
+ const snippetNames = new Set();
635
+ const snippetSlots = [];
636
+ for (const m of typeMembers) {
637
+ if (m.isSnippet) {
638
+ snippetNames.add(m.name);
639
+ snippetSlots.push({
640
+ name: m.name,
641
+ isDefault: m.name === 'children',
642
+ ...(m.description ? { description: m.description } : {}),
643
+ });
644
+ continue;
645
+ }
646
+ const propDef = {
647
+ name: m.name,
648
+ type: m.typeText,
649
+ required: !m.optional,
650
+ };
651
+ if (m.allowedValues)
652
+ propDef.allowedValues = m.allowedValues;
653
+ if (m.description)
654
+ propDef.description = m.description;
655
+ props.push(propDef);
656
+ }
657
+ return { props, snippetNames, snippetSlots, warnings: [] };
658
+ }
659
+ function propertyOrder(properties) {
660
+ const m = new Map();
661
+ let i = 0;
662
+ for (const p of properties) {
663
+ if (p.type !== 'Property')
664
+ continue;
665
+ const name = p['key']?.['name'] ?? null;
666
+ if (name)
667
+ m.set(name, i++);
668
+ }
669
+ return m;
670
+ }
671
+ function sortStable(a, b, order) {
672
+ const ai = order.get(a) ?? Infinity;
673
+ const bi = order.get(b) ?? Infinity;
674
+ if (ai !== bi)
675
+ return ai - bi;
676
+ return a.localeCompare(b);
677
+ }
678
+ function renderLiteral(node) {
679
+ if (!node)
680
+ return undefined;
681
+ if (node.type === 'Literal') {
682
+ const v = node['value'];
683
+ const raw = node['raw'];
684
+ if (typeof v === 'string')
685
+ return `'${v}'`;
686
+ if (typeof raw === 'string')
687
+ return raw;
688
+ if (v === null)
689
+ return 'null';
690
+ return String(v);
691
+ }
692
+ if (node.type === 'Identifier')
693
+ return node['name'] ?? undefined;
694
+ if (node.type === 'ArrayExpression') {
695
+ const els = node['elements'] ?? [];
696
+ return `[${els.map((e) => renderLiteral(e) ?? '').join(', ')}]`;
697
+ }
698
+ if (node.type === 'ObjectExpression')
699
+ return '{}';
700
+ return undefined;
701
+ }
702
+ // ---------------------------------------------------------------------------
703
+ // Imported Props resolution via ts-morph (cross-file)
704
+ // ---------------------------------------------------------------------------
705
+ async function resolveImportedTypeMembers(typeName, instance, filePath) {
706
+ const body = instance['content']?.['body'];
707
+ if (!body)
708
+ return null;
709
+ for (const stmt of body) {
710
+ if (stmt.type !== 'ImportDeclaration')
711
+ continue;
712
+ const specifierValue = stmt['source']?.['value'];
713
+ if (!specifierValue || !specifierValue.startsWith('.'))
714
+ continue;
715
+ const specifiers = stmt['specifiers'] ?? [];
716
+ let importedExport = null;
717
+ for (const spec of specifiers) {
718
+ if (spec.type !== 'ImportSpecifier')
719
+ continue;
720
+ const localName = spec['local']?.['name'];
721
+ if (localName === typeName) {
722
+ importedExport = spec['imported']?.['name'] ?? null;
723
+ break;
724
+ }
725
+ }
726
+ if (!importedExport)
727
+ continue;
728
+ const resolvedFile = resolveLocalScriptModule(filePath, specifierValue);
729
+ if (!resolvedFile)
730
+ continue;
731
+ return readMembersFromExternalFile(resolvedFile, importedExport);
732
+ }
733
+ return null;
734
+ }
735
+ function resolveLocalScriptModule(importingFilePath, specifier) {
736
+ const basePath = resolve(dirname(importingFilePath), specifier);
737
+ const candidates = [
738
+ basePath,
739
+ `${basePath}.js`,
740
+ `${basePath}.ts`,
741
+ `${basePath}.mjs`,
742
+ `${basePath}.cjs`,
743
+ join(basePath, 'index.js'),
744
+ join(basePath, 'index.ts'),
745
+ join(basePath, 'index.mjs'),
746
+ join(basePath, 'index.cjs'),
747
+ ];
748
+ for (const c of candidates) {
749
+ if (existsSync(c) && statSync(c).isFile())
750
+ return c;
751
+ }
752
+ // Fallback: handle `.js` import that maps to a `.ts` source on disk (NodeNext convention)
753
+ if (basePath.endsWith('.js')) {
754
+ const tsPath = `${basePath.slice(0, -'.js'.length)}.ts`;
755
+ if (existsSync(tsPath) && statSync(tsPath).isFile())
756
+ return tsPath;
757
+ }
758
+ return null;
759
+ }
760
+ function readMembersFromExternalFile(filePath, exportName) {
761
+ const project = new Project({
762
+ compilerOptions: { strict: false, target: 99, module: 99, allowJs: true },
763
+ useInMemoryFileSystem: false,
764
+ skipAddingFilesFromTsConfig: true,
765
+ });
766
+ const sf = project.addSourceFileAtPathIfExists(filePath);
767
+ if (!sf)
768
+ return null;
769
+ // Use getExportedDeclarations() so re-export chains (`export { ... } from './x'`,
770
+ // `export * from './x'`, named or namespace) resolve transparently. ts-morph
771
+ // follows them recursively and returns the underlying declaration.
772
+ const exportedDeclarations = sf.getExportedDeclarations();
773
+ const decls = exportedDeclarations.get(exportName);
774
+ if (!decls || decls.length === 0)
775
+ return null;
776
+ for (const decl of decls) {
777
+ if (Node.isInterfaceDeclaration(decl)) {
778
+ return readInterfaceMembers(decl);
779
+ }
780
+ if (Node.isTypeAliasDeclaration(decl)) {
781
+ const typeNode = decl.getTypeNode();
782
+ if (typeNode && Node.isTypeLiteral(typeNode)) {
783
+ return readTypeLiteralMembers(typeNode);
784
+ }
785
+ }
786
+ }
787
+ return null;
788
+ }
789
+ function readInterfaceMembers(iface) {
790
+ return iface.getProperties().map((prop) => {
791
+ const typeNode = prop.getTypeNode();
792
+ const typeText = typeNode ? typeNode.getText() : prop.getType().getText(prop);
793
+ const allowed = extractAllowedValuesFromText(typeText);
794
+ const jsdocs = prop.getJsDocs();
795
+ const description = jsdocs.length > 0 ? jsdocs[0].getDescription().trim() : undefined;
796
+ return {
797
+ name: prop.getName(),
798
+ optional: prop.hasQuestionToken(),
799
+ typeText,
800
+ isSnippet: typeText === 'Snippet' || /^Snippet</.test(typeText),
801
+ ...(allowed ? { allowedValues: allowed } : {}),
802
+ ...(description ? { description } : {}),
803
+ line: prop.getStartLineNumber(),
804
+ endLine: prop.getEndLineNumber(),
805
+ };
806
+ });
807
+ }
808
+ function readTypeLiteralMembers(typeNode) {
809
+ return typeNode.getMembers().flatMap((m) => {
810
+ if (!Node.isPropertySignature(m))
811
+ return [];
812
+ const tn = m.getTypeNode();
813
+ const typeText = tn ? tn.getText() : 'unknown';
814
+ const allowed = extractAllowedValuesFromText(typeText);
815
+ const jsdocs = m.getJsDocs();
816
+ const description = jsdocs.length > 0 ? jsdocs[0].getDescription().trim() : undefined;
817
+ return [
818
+ {
819
+ name: m.getName(),
820
+ optional: m.hasQuestionToken(),
821
+ typeText,
822
+ isSnippet: typeText === 'Snippet' || /^Snippet</.test(typeText),
823
+ ...(allowed ? { allowedValues: allowed } : {}),
824
+ ...(description ? { description } : {}),
825
+ line: m.getStartLineNumber(),
826
+ endLine: m.getEndLineNumber(),
827
+ },
828
+ ];
829
+ });
830
+ }
831
+ function extractAllowedValuesFromText(typeText) {
832
+ // Quick parse of `'a' | 'b' | 'c'`.
833
+ const parts = typeText
834
+ .split('|')
835
+ .map((p) => p.trim())
836
+ .filter(Boolean);
837
+ if (parts.length < 2)
838
+ return undefined;
839
+ const out = [];
840
+ for (const p of parts) {
841
+ const m = p.match(/^'([^']*)'$/) ?? p.match(/^"([^"]*)"$/);
842
+ if (!m)
843
+ return undefined;
844
+ out.push(m[1]);
845
+ }
846
+ return out.length > 0 ? out : undefined;
847
+ }
848
+ // ---------------------------------------------------------------------------
849
+ // Slot extraction (template <slot> + Snippet props merge)
850
+ // ---------------------------------------------------------------------------
851
+ function extractTemplateSlots(fragment) {
852
+ const slots = [];
853
+ const seen = new Set();
854
+ walk(fragment);
855
+ return slots;
856
+ function walk(node) {
857
+ if (!node)
858
+ return;
859
+ if (node.type === 'SlotElement') {
860
+ const name = readSlotName(node);
861
+ if (!seen.has(name)) {
862
+ seen.add(name);
863
+ slots.push({ name, isDefault: name === 'default' });
864
+ }
865
+ }
866
+ const children = node['nodes'] ?? node['children'] ?? [];
867
+ for (const c of children)
868
+ walk(c);
869
+ const fragChild = node['fragment'];
870
+ if (fragChild)
871
+ walk(fragChild);
872
+ }
873
+ }
874
+ function readSlotName(slotEl) {
875
+ const attrs = slotEl['attributes'] ?? [];
876
+ for (const attr of attrs) {
877
+ if (attr['name'] !== 'name')
878
+ continue;
879
+ const value = attr['value'];
880
+ if (Array.isArray(value)) {
881
+ for (const v of value) {
882
+ if (v.type === 'Text' && typeof v['data'] === 'string')
883
+ return v['data'];
884
+ }
885
+ }
886
+ }
887
+ return 'default';
888
+ }
889
+ function mergeSlots(fromSnippetProps, fromTemplate) {
890
+ if (fromSnippetProps.length === 0)
891
+ return { slots: fromTemplate, mixedWarning: false };
892
+ if (fromTemplate.length === 0)
893
+ return { slots: fromSnippetProps, mixedWarning: false };
894
+ const byName = new Map();
895
+ for (const s of fromSnippetProps)
896
+ byName.set(s.name, s);
897
+ const snippetHasDefault = fromSnippetProps.some((s) => s.isDefault);
898
+ let mixed = false;
899
+ for (const s of fromTemplate) {
900
+ // Default <slot/> in template is the same surface as a `children: Snippet` prop.
901
+ // Prefer the Snippet entry when both are present.
902
+ if (s.isDefault && snippetHasDefault) {
903
+ mixed = true;
904
+ continue;
905
+ }
906
+ if (byName.has(s.name)) {
907
+ mixed = true;
908
+ continue;
909
+ }
910
+ byName.set(s.name, s);
911
+ }
912
+ return { slots: [...byName.values()], mixedWarning: mixed };
913
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
- const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.ts', '.tsx', '.vue']);
3
+ const SCANNED_FILE_EXTENSIONS = new Set(['.astro', '.js', '.jsx', '.svelte', '.ts', '.tsx', '.vue']);
4
4
  const IMPORT_PATTERN = /import\s+(?:type\s+)?(.+?)\s+from\s+['"]([^'"]+)['"]/g;
5
5
  const EXPORT_NAMED_PATTERN = /export\s+(?:const|function|class|type|interface|enum)\s+([A-Za-z0-9_]+)/g;
6
6
  const EXPORT_DEFAULT_PATTERN = /export\s+default\s+([A-Za-z0-9_]+)/g;
@@ -5,7 +5,7 @@ export type AnalyzeViewResult = {
5
5
  fileCount: number;
6
6
  components: Array<{
7
7
  name: string;
8
- framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil';
8
+ framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil' | 'svelte';
9
9
  propCount: number;
10
10
  slotCount: number;
11
11
  warnings: string[];
@@ -32,7 +32,7 @@ export interface RawSlotDefinition {
32
32
  export interface RawComponentDefinition {
33
33
  name: string;
34
34
  source: string;
35
- framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil';
35
+ framework: 'react' | 'next' | 'vue' | 'astro' | 'web-component' | 'stencil' | 'svelte';
36
36
  props: RawPropDefinition[];
37
37
  slots: RawSlotDefinition[];
38
38
  /**
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-0373000.0",
3
+ "version": "2.11.4-dev-build-8f1adf3.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,9 +34,10 @@
34
34
  "react": "^18.3.1",
35
35
  "react-devtools-core": "^4.19.1",
36
36
  "react-dom": "^18.3.1",
37
+ "svelte": "^5.56.4",
37
38
  "ts-morph": "^27.0.2",
38
39
  "typescript": "^5.9.3",
39
- "@contentful/experience-design-system-types": "2.11.4-dev-build-0373000.0"
40
+ "@contentful/experience-design-system-types": "2.11.4-dev-build-8f1adf3.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@tsconfig/node24": "^24.0.3",