@contentful/experience-design-system-extraction 2.26.1
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/LICENSE +21 -0
- package/dist/package.json +59 -0
- package/dist/src/extract/astro.d.ts +5 -0
- package/dist/src/extract/astro.js +289 -0
- package/dist/src/extract/non-authorable-filter.d.ts +16 -0
- package/dist/src/extract/non-authorable-filter.js +60 -0
- package/dist/src/extract/pipeline.d.ts +6 -0
- package/dist/src/extract/pipeline.js +314 -0
- package/dist/src/extract/react.d.ts +2 -0
- package/dist/src/extract/react.js +2310 -0
- package/dist/src/extract/scoring.d.ts +12 -0
- package/dist/src/extract/scoring.js +108 -0
- package/dist/src/extract/slot-allowed-components.d.ts +6 -0
- package/dist/src/extract/slot-allowed-components.js +40 -0
- package/dist/src/extract/slot-detection.d.ts +35 -0
- package/dist/src/extract/slot-detection.js +101 -0
- package/dist/src/extract/source-inspection.d.ts +13 -0
- package/dist/src/extract/source-inspection.js +189 -0
- package/dist/src/extract/stencil.d.ts +2 -0
- package/dist/src/extract/stencil.js +328 -0
- package/dist/src/extract/structural-slot-evidence.d.ts +36 -0
- package/dist/src/extract/structural-slot-evidence.js +121 -0
- package/dist/src/extract/svelte.d.ts +5 -0
- package/dist/src/extract/svelte.js +1357 -0
- package/dist/src/extract/tsx-shared.d.ts +15 -0
- package/dist/src/extract/tsx-shared.js +276 -0
- package/dist/src/extract/validate.d.ts +16 -0
- package/dist/src/extract/validate.js +94 -0
- package/dist/src/extract/vue-tsx.d.ts +2 -0
- package/dist/src/extract/vue-tsx.js +498 -0
- package/dist/src/extract/vue.d.ts +5 -0
- package/dist/src/extract/vue.js +653 -0
- package/dist/src/extract/web-components.d.ts +2 -0
- package/dist/src/extract/web-components.js +876 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +22 -0
- package/dist/src/pre-classify.d.ts +17 -0
- package/dist/src/pre-classify.js +225 -0
- package/dist/src/types.d.ts +91 -0
- package/dist/src/types.js +4 -0
- package/package.json +58 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { Project, Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
import { getJsxTagNameNode, isIntrinsicJsxElement } from './tsx-shared.js';
|
|
3
|
+
function isStencilFile(sourceFile) {
|
|
4
|
+
return sourceFile.getImportDeclarations().some((imp) => imp.getModuleSpecifierValue() === '@stencil/core');
|
|
5
|
+
}
|
|
6
|
+
function kebabToPascal(input) {
|
|
7
|
+
return input
|
|
8
|
+
.split('-')
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
11
|
+
.join('');
|
|
12
|
+
}
|
|
13
|
+
function getComponentTag(classDecl) {
|
|
14
|
+
for (const decorator of classDecl.getDecorators()) {
|
|
15
|
+
if (decorator.getName() !== 'Component')
|
|
16
|
+
continue;
|
|
17
|
+
const args = decorator.getArguments();
|
|
18
|
+
if (args.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
const arg = args[0];
|
|
21
|
+
if (!Node.isObjectLiteralExpression(arg))
|
|
22
|
+
continue;
|
|
23
|
+
const tagProp = arg.getProperty('tag');
|
|
24
|
+
if (!tagProp || !Node.isPropertyAssignment(tagProp))
|
|
25
|
+
continue;
|
|
26
|
+
const initializer = tagProp.getInitializer();
|
|
27
|
+
if (!initializer || !Node.isStringLiteral(initializer))
|
|
28
|
+
continue;
|
|
29
|
+
return initializer.getLiteralValue();
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function hasDecorator(node, decoratorName) {
|
|
34
|
+
if (!Node.isPropertyDeclaration(node))
|
|
35
|
+
return false;
|
|
36
|
+
return node.getDecorators().some((d) => d.getName() === decoratorName);
|
|
37
|
+
}
|
|
38
|
+
function extractAllowedValues(typeText) {
|
|
39
|
+
// Match inline string literal unions: 'a' | 'b' | 'c'
|
|
40
|
+
const literalPattern = /^'[^']*'(?:\s*\|\s*'[^']*')+$/;
|
|
41
|
+
if (!literalPattern.test(typeText.trim()))
|
|
42
|
+
return undefined;
|
|
43
|
+
const values = typeText
|
|
44
|
+
.split('|')
|
|
45
|
+
.map((v) => v.trim().replace(/^'|'$/g, ''))
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.sort();
|
|
48
|
+
return values.length >= 2 ? values : undefined;
|
|
49
|
+
}
|
|
50
|
+
function collectIntrinsicDomAttributeProps(classDecl, propNames) {
|
|
51
|
+
const domAttributeProps = new Set();
|
|
52
|
+
const renderMethod = classDecl.getMethod('render');
|
|
53
|
+
if (!renderMethod)
|
|
54
|
+
return domAttributeProps;
|
|
55
|
+
for (const attribute of renderMethod.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
|
|
56
|
+
const tagName = getJsxTagNameNode(attribute)?.getText();
|
|
57
|
+
const attributeName = attribute.getNameNode().getText();
|
|
58
|
+
if (!tagName || !propNames.has(attributeName) || !isIntrinsicJsxElement(tagName)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const initializer = attribute.getInitializer();
|
|
62
|
+
if (!initializer || !Node.isJsxExpression(initializer))
|
|
63
|
+
continue;
|
|
64
|
+
const expression = initializer.getExpression();
|
|
65
|
+
if (!expression || !Node.isPropertyAccessExpression(expression))
|
|
66
|
+
continue;
|
|
67
|
+
if (!Node.isThisExpression(expression.getExpression()))
|
|
68
|
+
continue;
|
|
69
|
+
if (expression.getName() !== attributeName)
|
|
70
|
+
continue;
|
|
71
|
+
domAttributeProps.add(attributeName);
|
|
72
|
+
}
|
|
73
|
+
return domAttributeProps;
|
|
74
|
+
}
|
|
75
|
+
function extractProps(classDecl) {
|
|
76
|
+
const props = [];
|
|
77
|
+
const propNames = new Set(classDecl
|
|
78
|
+
.getProperties()
|
|
79
|
+
.filter((property) => hasDecorator(property, 'Prop'))
|
|
80
|
+
.map((property) => property.getName()));
|
|
81
|
+
const intrinsicDomAttributeProps = collectIntrinsicDomAttributeProps(classDecl, propNames);
|
|
82
|
+
for (const property of classDecl.getProperties()) {
|
|
83
|
+
if (!hasDecorator(property, 'Prop'))
|
|
84
|
+
continue;
|
|
85
|
+
const name = property.getName();
|
|
86
|
+
const typeNode = property.getTypeNode();
|
|
87
|
+
const typeText = typeNode ? typeNode.getText() : 'unknown';
|
|
88
|
+
const hasQuestionToken = property.hasQuestionToken();
|
|
89
|
+
const hasExclamation = property.hasExclamationToken();
|
|
90
|
+
const initializer = property.getInitializer();
|
|
91
|
+
let defaultValue;
|
|
92
|
+
if (initializer) {
|
|
93
|
+
if (Node.isStringLiteral(initializer)) {
|
|
94
|
+
defaultValue = initializer.getLiteralValue();
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
defaultValue = initializer.getText();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const isRequired = hasExclamation || (!hasQuestionToken && !initializer);
|
|
101
|
+
// JSDoc description
|
|
102
|
+
const jsDocs = property.getJsDocs();
|
|
103
|
+
let description;
|
|
104
|
+
let isDeprecated = false;
|
|
105
|
+
if (jsDocs.length > 0) {
|
|
106
|
+
const jsDoc = jsDocs[0];
|
|
107
|
+
description = jsDoc.getDescription().trim() || undefined;
|
|
108
|
+
for (const tag of jsDoc.getTags()) {
|
|
109
|
+
if (tag.getTagName() === 'deprecated') {
|
|
110
|
+
isDeprecated = true;
|
|
111
|
+
// The deprecated tag may carry a message (e.g. "@deprecated Use size instead.")
|
|
112
|
+
const tagComment = tag.getCommentText()?.trim();
|
|
113
|
+
if (tagComment && !description) {
|
|
114
|
+
description = tagComment;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (isDeprecated && description) {
|
|
120
|
+
description = `[DEPRECATED] ${description}`;
|
|
121
|
+
}
|
|
122
|
+
else if (isDeprecated) {
|
|
123
|
+
description = '[DEPRECATED]';
|
|
124
|
+
}
|
|
125
|
+
const allowedValues = extractAllowedValues(typeText);
|
|
126
|
+
props.push({
|
|
127
|
+
name,
|
|
128
|
+
type: typeText,
|
|
129
|
+
required: isRequired,
|
|
130
|
+
...(defaultValue !== undefined && { defaultValue }),
|
|
131
|
+
...(description && { description }),
|
|
132
|
+
...(allowedValues && { allowedValues }),
|
|
133
|
+
...(intrinsicDomAttributeProps.has(name) && { domAttribute: true }),
|
|
134
|
+
sourceStartLine: property.getStartLineNumber(),
|
|
135
|
+
sourceEndLine: property.getEndLineNumber(),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return props.sort((a, b) => a.name.localeCompare(b.name));
|
|
139
|
+
}
|
|
140
|
+
function normalizeSlotName(name) {
|
|
141
|
+
const normalizedName = name && name.length > 0 ? name : 'default';
|
|
142
|
+
return {
|
|
143
|
+
name: normalizedName,
|
|
144
|
+
isDefault: normalizedName === 'default',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function extractSlots(classDecl, warnings, componentName) {
|
|
148
|
+
const slots = new Map();
|
|
149
|
+
const upsertSlot = (slot) => {
|
|
150
|
+
const existing = slots.get(slot.name);
|
|
151
|
+
if (!existing) {
|
|
152
|
+
slots.set(slot.name, slot);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
slots.set(slot.name, {
|
|
156
|
+
...existing,
|
|
157
|
+
...slot,
|
|
158
|
+
description: existing.description ?? slot.description,
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
const jsDocs = classDecl.getJsDocs();
|
|
162
|
+
for (const jsDoc of jsDocs) {
|
|
163
|
+
for (const tag of jsDoc.getTags()) {
|
|
164
|
+
if (tag.getTagName() !== 'slot')
|
|
165
|
+
continue;
|
|
166
|
+
const comment = tag.getCommentText()?.trim();
|
|
167
|
+
if (!comment)
|
|
168
|
+
continue;
|
|
169
|
+
if (comment.startsWith('{')) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = JSON.parse(comment);
|
|
172
|
+
const slot = normalizeSlotName(parsed.name);
|
|
173
|
+
let description = parsed.description || undefined;
|
|
174
|
+
if (parsed.isDeprecated && description) {
|
|
175
|
+
description = `[DEPRECATED] ${description}`;
|
|
176
|
+
}
|
|
177
|
+
else if (parsed.isDeprecated) {
|
|
178
|
+
description = '[DEPRECATED]';
|
|
179
|
+
}
|
|
180
|
+
upsertSlot({
|
|
181
|
+
...slot,
|
|
182
|
+
...(description && { description }),
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
warnings.push(`Failed to parse @slot JSDoc in ${componentName}: invalid JSON "${comment}"`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const match = comment.match(/^(?:(\S+)\s*-\s*)?(.*)$/s);
|
|
192
|
+
if (!match)
|
|
193
|
+
continue;
|
|
194
|
+
const [, rawName, rawDescription] = match;
|
|
195
|
+
const slot = normalizeSlotName(rawName);
|
|
196
|
+
const description = rawDescription.trim() || undefined;
|
|
197
|
+
upsertSlot({
|
|
198
|
+
...slot,
|
|
199
|
+
...(description && { description }),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const method of classDecl.getMethods()) {
|
|
204
|
+
method.forEachDescendant((node) => {
|
|
205
|
+
if (!Node.isJsxSelfClosingElement(node) && !Node.isJsxElement(node))
|
|
206
|
+
return;
|
|
207
|
+
const openingElement = Node.isJsxElement(node) ? node.getOpeningElement() : node;
|
|
208
|
+
const tagName = openingElement.getTagNameNode().getText();
|
|
209
|
+
const slotAttribute = openingElement
|
|
210
|
+
.getAttributes()
|
|
211
|
+
.find((attribute) => Node.isJsxAttribute(attribute) && attribute.getNameNode().getText() === 'slot');
|
|
212
|
+
if (tagName === 'slot') {
|
|
213
|
+
const nameAttribute = openingElement
|
|
214
|
+
.getAttributes()
|
|
215
|
+
.find((attribute) => Node.isJsxAttribute(attribute) && attribute.getNameNode().getText() === 'name');
|
|
216
|
+
const initializer = Node.isJsxAttribute(nameAttribute) ? nameAttribute.getInitializer() : undefined;
|
|
217
|
+
const name = initializer && Node.isStringLiteral(initializer)
|
|
218
|
+
? initializer.getLiteralValue()
|
|
219
|
+
: initializer && Node.isJsxExpression(initializer)
|
|
220
|
+
? (initializer.getExpression()?.getText() ?? '')
|
|
221
|
+
: '';
|
|
222
|
+
upsertSlot({
|
|
223
|
+
...normalizeSlotName(name),
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!Node.isJsxAttribute(slotAttribute))
|
|
228
|
+
return;
|
|
229
|
+
const initializer = slotAttribute.getInitializer();
|
|
230
|
+
if (!initializer || !Node.isStringLiteral(initializer))
|
|
231
|
+
return;
|
|
232
|
+
const name = initializer.getLiteralValue();
|
|
233
|
+
if (!name)
|
|
234
|
+
return;
|
|
235
|
+
upsertSlot({
|
|
236
|
+
name,
|
|
237
|
+
isDefault: false,
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return [...slots.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
242
|
+
}
|
|
243
|
+
function detectEvents(classDecl) {
|
|
244
|
+
const eventNames = [];
|
|
245
|
+
for (const property of classDecl.getProperties()) {
|
|
246
|
+
if (!hasDecorator(property, 'Event'))
|
|
247
|
+
continue;
|
|
248
|
+
eventNames.push(property.getName());
|
|
249
|
+
}
|
|
250
|
+
return eventNames.sort();
|
|
251
|
+
}
|
|
252
|
+
function extractFromSourceFile(sourceFile, warnings) {
|
|
253
|
+
const components = [];
|
|
254
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
255
|
+
const tag = getComponentTag(classDecl);
|
|
256
|
+
if (!tag)
|
|
257
|
+
continue;
|
|
258
|
+
const name = kebabToPascal(tag);
|
|
259
|
+
const props = extractProps(classDecl);
|
|
260
|
+
const slots = extractSlots(classDecl, warnings, name);
|
|
261
|
+
const eventNames = detectEvents(classDecl);
|
|
262
|
+
if (eventNames.length > 0) {
|
|
263
|
+
warnings.push(`Component ${name} has ${eventNames.length} events not captured: ${eventNames.join(', ')}`);
|
|
264
|
+
}
|
|
265
|
+
components.push({
|
|
266
|
+
name,
|
|
267
|
+
source: sourceFile.getFilePath(),
|
|
268
|
+
sourcePath: sourceFile.getFilePath(),
|
|
269
|
+
framework: 'stencil',
|
|
270
|
+
props,
|
|
271
|
+
slots,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return components;
|
|
275
|
+
}
|
|
276
|
+
function detectFunctionalComponents(sourceFile, warnings) {
|
|
277
|
+
for (const [exportName, declarations] of sourceFile.getExportedDeclarations()) {
|
|
278
|
+
for (const decl of declarations) {
|
|
279
|
+
if (!Node.isVariableDeclaration(decl))
|
|
280
|
+
continue;
|
|
281
|
+
const typeNode = decl.getTypeNode();
|
|
282
|
+
if (!typeNode)
|
|
283
|
+
continue;
|
|
284
|
+
const typeText = typeNode.getText();
|
|
285
|
+
if (!typeText.startsWith('FunctionalComponent'))
|
|
286
|
+
continue;
|
|
287
|
+
warnings.push(`Stencil FunctionalComponent detected but not extracted: ${exportName} in ${sourceFile.getFilePath()}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export async function extractStencilComponents(filePaths) {
|
|
292
|
+
const tsxFiles = filePaths.filter((f) => /\.[jt]sx$/.test(f));
|
|
293
|
+
if (tsxFiles.length === 0) {
|
|
294
|
+
return { components: [], warnings: [] };
|
|
295
|
+
}
|
|
296
|
+
const project = new Project({
|
|
297
|
+
compilerOptions: {
|
|
298
|
+
jsx: 1, // JsxEmit.Preserve
|
|
299
|
+
target: 99, // ScriptTarget.ESNext
|
|
300
|
+
module: 99, // ModuleKind.ESNext
|
|
301
|
+
moduleResolution: 100, // ModuleResolutionKind.Bundler
|
|
302
|
+
skipLibCheck: true,
|
|
303
|
+
allowJs: true,
|
|
304
|
+
},
|
|
305
|
+
skipAddingFilesFromTsConfig: true,
|
|
306
|
+
});
|
|
307
|
+
for (const filePath of tsxFiles) {
|
|
308
|
+
project.addSourceFileAtPath(filePath);
|
|
309
|
+
}
|
|
310
|
+
const warnings = [];
|
|
311
|
+
const components = [];
|
|
312
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
313
|
+
try {
|
|
314
|
+
if (!isStencilFile(sourceFile))
|
|
315
|
+
continue;
|
|
316
|
+
const extracted = extractFromSourceFile(sourceFile, warnings);
|
|
317
|
+
components.push(...extracted);
|
|
318
|
+
detectFunctionalComponents(sourceFile, warnings);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
warnings.push(`Failed to extract from ${sourceFile.getFilePath()}: ${e instanceof Error ? e.message : String(e)}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
components: components.sort((a, b) => a.name.localeCompare(b.name)),
|
|
326
|
+
warnings,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type FunctionDeclaration, type ArrowFunction, type FunctionExpression, type SourceFile } from 'ts-morph';
|
|
2
|
+
import { type AllowedComponentsContext } from './slot-allowed-components.js';
|
|
3
|
+
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
|
|
4
|
+
/**
|
|
5
|
+
* Structural composition evidence: parent→child relationships a component's
|
|
6
|
+
* source implies through *usage*, not through a declared slot contract
|
|
7
|
+
* (`ReactElement<XProps>` on the slot's own prop type, `@allowedComponents`,
|
|
8
|
+
* a mapping-layer keyword). Declared contracts still win on conflict — see
|
|
9
|
+
* the `structural` provenance rank in the CLI's composition merge — this is
|
|
10
|
+
* a lower-trust supplementary signal for the common case where a component
|
|
11
|
+
* accepts `children: ReactNode` and narrows it at runtime instead.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Signal A — a type-predicate function anywhere in the file asserts a value
|
|
15
|
+
* `is ReactElement<XProps>` (e.g. `function isFooElement(c): c is
|
|
16
|
+
* ReactElement<FooProps>`). Common alongside a runtime `Children.map` /
|
|
17
|
+
* `cloneElement` narrowing pattern where the slot's own prop type is a bare
|
|
18
|
+
* `ReactNode` and carries no generic for the typed-slot extractor to read.
|
|
19
|
+
*/
|
|
20
|
+
export declare function collectTypePredicateComponentReferences(sourceFile: SourceFile, ctx: AllowedComponentsContext): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Signal B — a runtime identity check against a known component, e.g.
|
|
23
|
+
* `isValidElement(child) && child.type === BlueAccordionItem`. `.type` on a
|
|
24
|
+
* React element is the actual component reference, so this is direct
|
|
25
|
+
* evidence the checked value is expected to BE that component — not a name
|
|
26
|
+
* guess, an identity comparison against the real imported binding.
|
|
27
|
+
*/
|
|
28
|
+
export declare function collectRuntimeTypeCheckComponentReferences(sourceFile: SourceFile, componentNames: ReadonlySet<string>): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Signal C — a component's own render body literally instantiates another
|
|
31
|
+
* known local component as JSX (e.g. `<CardHeader/>` inside `Card`'s
|
|
32
|
+
* return). Direct structural nesting, not a slot being filled with
|
|
33
|
+
* caller-supplied content — the weakest-trust signal, kept last in the rank.
|
|
34
|
+
*/
|
|
35
|
+
export declare function collectRenderedComponentReferences(funcNode: FunctionLike, componentNames: ReadonlySet<string>, ownComponentName: string): string[];
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Node, SyntaxKind, } from 'ts-morph';
|
|
2
|
+
import { extractAllowedComponentsFromTypeText } from './slot-allowed-components.js';
|
|
3
|
+
import { isIntrinsicJsxElement } from './tsx-shared.js';
|
|
4
|
+
/**
|
|
5
|
+
* Structural composition evidence: parent→child relationships a component's
|
|
6
|
+
* source implies through *usage*, not through a declared slot contract
|
|
7
|
+
* (`ReactElement<XProps>` on the slot's own prop type, `@allowedComponents`,
|
|
8
|
+
* a mapping-layer keyword). Declared contracts still win on conflict — see
|
|
9
|
+
* the `structural` provenance rank in the CLI's composition merge — this is
|
|
10
|
+
* a lower-trust supplementary signal for the common case where a component
|
|
11
|
+
* accepts `children: ReactNode` and narrows it at runtime instead.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Signal A — a type-predicate function anywhere in the file asserts a value
|
|
15
|
+
* `is ReactElement<XProps>` (e.g. `function isFooElement(c): c is
|
|
16
|
+
* ReactElement<FooProps>`). Common alongside a runtime `Children.map` /
|
|
17
|
+
* `cloneElement` narrowing pattern where the slot's own prop type is a bare
|
|
18
|
+
* `ReactNode` and carries no generic for the typed-slot extractor to read.
|
|
19
|
+
*/
|
|
20
|
+
export function collectTypePredicateComponentReferences(sourceFile, ctx) {
|
|
21
|
+
const found = new Set();
|
|
22
|
+
const candidates = [
|
|
23
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionDeclaration),
|
|
24
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
25
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
26
|
+
];
|
|
27
|
+
for (const fn of candidates) {
|
|
28
|
+
const returnTypeNode = fn.getReturnTypeNode();
|
|
29
|
+
if (!returnTypeNode || !Node.isTypePredicate(returnTypeNode))
|
|
30
|
+
continue;
|
|
31
|
+
const assertedTypeNode = returnTypeNode.getTypeNode();
|
|
32
|
+
if (!assertedTypeNode)
|
|
33
|
+
continue;
|
|
34
|
+
for (const name of extractAllowedComponentsFromTypeText(assertedTypeNode.getText(), ctx)) {
|
|
35
|
+
found.add(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...found].sort();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Signal B — a runtime identity check against a known component, e.g.
|
|
42
|
+
* `isValidElement(child) && child.type === BlueAccordionItem`. `.type` on a
|
|
43
|
+
* React element is the actual component reference, so this is direct
|
|
44
|
+
* evidence the checked value is expected to BE that component — not a name
|
|
45
|
+
* guess, an identity comparison against the real imported binding.
|
|
46
|
+
*/
|
|
47
|
+
export function collectRuntimeTypeCheckComponentReferences(sourceFile, componentNames) {
|
|
48
|
+
const found = new Set();
|
|
49
|
+
const localBindings = collectLocallyBoundNames(sourceFile);
|
|
50
|
+
for (const binary of sourceFile.getDescendantsOfKind(SyntaxKind.BinaryExpression)) {
|
|
51
|
+
const operator = binary.getOperatorToken().getText();
|
|
52
|
+
if (operator !== '===' && operator !== '==')
|
|
53
|
+
continue;
|
|
54
|
+
const left = binary.getLeft();
|
|
55
|
+
const right = binary.getRight();
|
|
56
|
+
const candidateFromTypeAccess = (typeAccessSide, otherSide) => {
|
|
57
|
+
if (!Node.isPropertyAccessExpression(typeAccessSide) || typeAccessSide.getName() !== 'type')
|
|
58
|
+
return undefined;
|
|
59
|
+
if (!Node.isIdentifier(otherSide) && !Node.isPropertyAccessExpression(otherSide))
|
|
60
|
+
return undefined;
|
|
61
|
+
const name = otherSide.getText().split('.').pop();
|
|
62
|
+
if (!name || !componentNames.has(name))
|
|
63
|
+
return undefined;
|
|
64
|
+
const baseIdentifier = Node.isPropertyAccessExpression(otherSide) ? otherSide.getExpression() : otherSide;
|
|
65
|
+
if (!Node.isIdentifier(baseIdentifier) || !localBindings.has(baseIdentifier.getText()))
|
|
66
|
+
return undefined;
|
|
67
|
+
return name;
|
|
68
|
+
};
|
|
69
|
+
const match = candidateFromTypeAccess(left, right) ?? candidateFromTypeAccess(right, left);
|
|
70
|
+
if (match)
|
|
71
|
+
found.add(match);
|
|
72
|
+
}
|
|
73
|
+
return [...found].sort();
|
|
74
|
+
}
|
|
75
|
+
/** Names bound in this file by import or local declaration — used to keep signal B from matching an unrelated same-named identifier that was never actually imported here. */
|
|
76
|
+
function collectLocallyBoundNames(sourceFile) {
|
|
77
|
+
const names = new Set();
|
|
78
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
79
|
+
const defaultImport = imp.getDefaultImport();
|
|
80
|
+
if (defaultImport)
|
|
81
|
+
names.add(defaultImport.getText());
|
|
82
|
+
const namespaceImport = imp.getNamespaceImport();
|
|
83
|
+
if (namespaceImport)
|
|
84
|
+
names.add(namespaceImport.getText());
|
|
85
|
+
for (const named of imp.getNamedImports()) {
|
|
86
|
+
names.add((named.getAliasNode() ?? named.getNameNode()).getText());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const decl of sourceFile.getVariableDeclarations())
|
|
90
|
+
names.add(decl.getName());
|
|
91
|
+
for (const decl of sourceFile.getFunctions()) {
|
|
92
|
+
const declName = decl.getName();
|
|
93
|
+
if (declName)
|
|
94
|
+
names.add(declName);
|
|
95
|
+
}
|
|
96
|
+
return names;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Signal C — a component's own render body literally instantiates another
|
|
100
|
+
* known local component as JSX (e.g. `<CardHeader/>` inside `Card`'s
|
|
101
|
+
* return). Direct structural nesting, not a slot being filled with
|
|
102
|
+
* caller-supplied content — the weakest-trust signal, kept last in the rank.
|
|
103
|
+
*/
|
|
104
|
+
export function collectRenderedComponentReferences(funcNode, componentNames, ownComponentName) {
|
|
105
|
+
const found = new Set();
|
|
106
|
+
const jsxElements = [
|
|
107
|
+
...funcNode.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
108
|
+
...funcNode.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
109
|
+
];
|
|
110
|
+
for (const jsxElement of jsxElements) {
|
|
111
|
+
const tagName = jsxElement.getTagNameNode().getText();
|
|
112
|
+
if (isIntrinsicJsxElement(tagName))
|
|
113
|
+
continue;
|
|
114
|
+
if (tagName === ownComponentName)
|
|
115
|
+
continue;
|
|
116
|
+
if (!componentNames.has(tagName))
|
|
117
|
+
continue;
|
|
118
|
+
found.add(tagName);
|
|
119
|
+
}
|
|
120
|
+
return [...found].sort();
|
|
121
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ComponentExtractionResult, ExtractorOptions } from '../types.js';
|
|
2
|
+
export declare function extractSvelteComponents(filePaths: string[], onProgress?: (p: {
|
|
3
|
+
filesProcessed: number;
|
|
4
|
+
componentsFound: number;
|
|
5
|
+
}) => void, opts?: ExtractorOptions): Promise<ComponentExtractionResult>;
|