@@ -3,6 +3,8 @@ import { basename, dirname, extname, join } from 'node:path';
33
44import { parseSync } from 'oxc-parser' ;
55
6+ import { isIgnoredPath } from '../ignored-dirs.js' ;
7+
68// Component scanner — finds the project's existing components so component_map can join Figma names
79// against them. The guiding principle: never pattern-match the directory layout (feature-based, atomic,
810// flat all differ); identify a component by its *AST signature* (a PascalCase, exported, function-ish
@@ -17,22 +19,19 @@ export interface ScannedComponent {
1719 /** Repo-relative path. */
1820 filePath : string ;
1921 exportKind : 'default' | 'named' ;
20- /** Best-effort destructured prop names from the component's first param; [] when not extractable . */
22+ /** The component's prop names; [] when the component has none OR they couldn't be parsed . */
2123 propNames : string [ ] ;
24+ /**
25+ * Whether propNames is a real parse result (so [] means "genuinely no props") versus a baseline
26+ * that couldn't read props (so [] means "unknown"). The component join uses this to avoid
27+ * reporting every variant axis as an unmatched prop just because we never parsed the props — a
28+ * false "extend this component" TODO. True once we've parsed the source; false only on a parse
29+ * failure.
30+ */
31+ propsExtracted : boolean ;
2232 framework : ComponentFramework ;
2333}
2434
25- /** Directories never worth walking. */
26- const IGNORED_DIRS = new Set ( [
27- 'node_modules' ,
28- 'dist' ,
29- 'build' ,
30- '.next' ,
31- '.nuxt' ,
32- '.git' ,
33- 'coverage' ,
34- ] ) ;
35-
3635/** React HOCs whose call wraps a component function — the binding is still a component. */
3736const COMPONENT_WRAPPERS = new Set ( [ 'forwardRef' , 'memo' , 'observer' ] ) ;
3837
@@ -120,23 +119,188 @@ export const extractReactComponents = (filePath: string, code: string): ScannedC
120119 filePath,
121120 exportKind : cand . exportKind ,
122121 propNames : propNamesOf ( cand . fn ) ,
122+ propsExtracted : true ,
123123 framework : 'react' ,
124124 } ) ;
125125 }
126126 return out ;
127127} ;
128128
129+ /* eslint-disable @typescript-eslint/no-explicit-any -- shared oxc AST walker below */
130+
131+ /** Depth-first walk of an oxc/ESTree node, yielding every CallExpression encountered. */
132+ const collectCalls = ( root : any ) : any [ ] => {
133+ const out : any [ ] = [ ] ;
134+ const visit = ( node : any ) : void => {
135+ if ( node === null || typeof node !== 'object' ) return ;
136+ if ( node . type === 'CallExpression' ) out . push ( node ) ;
137+ for ( const key of Object . keys ( node ) ) {
138+ const v = ( node as Record < string , unknown > ) [ key ] ;
139+ if ( Array . isArray ( v ) ) for ( const c of v ) visit ( c ) ;
140+ else if ( v !== null && typeof v === 'object' ) visit ( v ) ;
141+ }
142+ } ;
143+ visit ( root ) ;
144+ return out ;
145+ } ;
146+
147+ /** Prop names from a `defineProps` call: a type literal, an object, or an array of string keys. */
148+ const definePropsNames = ( call : any ) : string [ ] => {
149+ // Type form: defineProps<{ size?: string; variant: 'a' | 'b' }>(). oxc exposes the instantiation as
150+ // typeArguments (older trees: typeParameters); its first param is a TSTypeLiteral whose members'
151+ // keys are the prop names.
152+ const typeArgs = call . typeArguments ?? call . typeParameters ;
153+ const typeLiteral = typeArgs ?. params ?. [ 0 ] ;
154+ if ( typeLiteral ?. type === 'TSTypeLiteral' ) {
155+ return ( typeLiteral . members ?? [ ] )
156+ . map ( ( m : any ) => m ?. key ?. name )
157+ . filter ( ( n : unknown ) : n is string => typeof n === 'string' ) ;
158+ }
159+ const arg0 = call . arguments ?. [ 0 ] ;
160+ // Object form: defineProps({ size: String, variant: { type: String } }) → keys.
161+ if ( arg0 ?. type === 'ObjectExpression' ) {
162+ return ( arg0 . properties ?? [ ] )
163+ . map ( ( p : any ) => p ?. key ?. name ?? p ?. key ?. value )
164+ . filter ( ( n : unknown ) : n is string => typeof n === 'string' ) ;
165+ }
166+ // Array form: defineProps(['size', 'variant']) → the string literals.
167+ if ( arg0 ?. type === 'ArrayExpression' ) {
168+ return ( arg0 . elements ?? [ ] )
169+ . map ( ( e : any ) => ( e ?. type === 'Literal' ? e . value : undefined ) )
170+ . filter ( ( n : unknown ) : n is string => typeof n === 'string' ) ;
171+ }
172+ return [ ] ;
173+ } ;
174+
175+ /** Names from a `props: { … } | [ … ]` member of an object (Vue Options API props declaration). */
176+ const propsMemberNames = ( obj : any ) : string [ ] => {
177+ const propsProp = ( obj ?. properties ?? [ ] ) . find (
178+ ( p : any ) => ( p ?. key ?. name ?? p ?. key ?. value ) === 'props' ,
179+ ) ;
180+ const value = propsProp ?. value ;
181+ if ( value ?. type === 'ObjectExpression' ) {
182+ return ( value . properties ?? [ ] )
183+ . map ( ( p : any ) => p ?. key ?. name ?? p ?. key ?. value )
184+ . filter ( ( n : unknown ) : n is string => typeof n === 'string' ) ;
185+ }
186+ if ( value ?. type === 'ArrayExpression' ) {
187+ return ( value . elements ?? [ ] )
188+ . map ( ( e : any ) => ( e ?. type === 'Literal' ? e . value : undefined ) )
189+ . filter ( ( n : unknown ) : n is string => typeof n === 'string' ) ;
190+ }
191+ return [ ] ;
192+ } ;
193+
194+ /**
195+ * Vue Options API prop names: `export default { props: { … } }` — or wrapped in defineComponent /
196+ * defineNuxtComponent. The default export is either an object or a call whose first arg is the
197+ * object.
198+ */
199+ const vueOptionsPropsNames = ( program : any ) : string [ ] => {
200+ for ( const node of program . body ?? [ ] ) {
201+ if ( node . type !== 'ExportDefaultDeclaration' ) continue ;
202+ const d = node . declaration ;
203+ const obj = d ?. type === 'ObjectExpression' ? d : d ?. arguments ?. [ 0 ] ;
204+ if ( obj ?. type === 'ObjectExpression' ) return propsMemberNames ( obj ) ;
205+ }
206+ return [ ] ;
207+ } ;
208+
209+ /** Svelte prop names: `export let foo` (Svelte 4) and `let { a, b } = $props()` (Svelte 5 runes). */
210+ const sveltePropNames = ( program : any ) : string [ ] => {
211+ const names = new Set < string > ( ) ;
212+ for ( const node of program . body ?? [ ] ) {
213+ if (
214+ node . type === 'ExportNamedDeclaration' &&
215+ node . declaration ?. type === 'VariableDeclaration' &&
216+ node . declaration . kind === 'let'
217+ ) {
218+ for ( const d of node . declaration . declarations ?? [ ] )
219+ if ( d ?. id ?. type === 'Identifier' && typeof d . id . name === 'string' ) names . add ( d . id . name ) ;
220+ }
221+ }
222+ // Svelte 5 runes: `let { a, b } = $props()` — a destructuring declarator initialized by $props().
223+ for ( const node of program . body ?? [ ] ) {
224+ if ( node . type !== 'VariableDeclaration' ) continue ;
225+ for ( const d of node . declarations ?? [ ] ) {
226+ if ( d ?. init ?. type === 'CallExpression' && d . init . callee ?. name === '$props' ) {
227+ for ( const p of d . id ?. properties ?? [ ] )
228+ if ( p ?. key ?. name && typeof p . key . name === 'string' ) names . add ( p . key . name ) ;
229+ }
230+ }
231+ }
232+ return [ ...names ] ;
233+ } ;
234+
129235/* eslint-enable @typescript-eslint/no-explicit-any */
130236
131237const REACT_EXTS = new Set ( [ '.tsx' , '.jsx' ] ) ;
132238
133- /** Baseline extractor for SFC frameworks: the file is the component, name derived from the path. */
134- const extractSfcComponent = (
239+ // Pull every <script> / <script setup> body out of an SFC. lang="ts" (or its absence) decides the
240+ // parser dialect; a .vue/.svelte file with no script block is a genuinely prop-less template.
241+ const SCRIPT_BLOCK = / < s c r i p t \b ( [ ^ > ] * ) > ( [ \s \S ] * ?) < \/ s c r i p t > / gi;
242+
243+ interface ScriptBlock {
244+ body : string ;
245+ ts : boolean ;
246+ }
247+
248+ const extractScriptBlocks = ( code : string ) : ScriptBlock [ ] => {
249+ const out : ScriptBlock [ ] = [ ] ;
250+ for ( const m of code . matchAll ( SCRIPT_BLOCK ) ) {
251+ const attrs = m [ 1 ] ?? '' ;
252+ out . push ( { body : m [ 2 ] ?? '' , ts : / \b l a n g = [ " ' ] t s [ " ' ] / . test ( attrs ) } ) ;
253+ }
254+ return out ;
255+ } ;
256+
257+ /**
258+ * Extract a single-file component (Vue / Svelte). The file is the component (its name is the file
259+ * by convention); props come from the <script> block — Vue's defineProps (type / object / array
260+ * forms) and Options-API `props`, Svelte's `export let` / `$props()`.
261+ *
262+ * PropsExtracted distinguishes "[] = genuinely no props" from "[] = unknown" so the join won't
263+ * invent extension TODOs. It's true only when we either found props, or the file is a script-less
264+ * (so genuinely prop-less) template. When a script is present but we read no props — a parse error,
265+ * or a prop-declaration style we don't recognize — it stays false (conservative: the join then
266+ * suppresses matched/unmatched rather than asserting prop gaps we can't actually see). oxc doesn't
267+ * throw on bad input, so a parse failure surfaces here simply as "no props found".
268+ */
269+ export const extractSfcComponent = (
135270 filePath : string ,
271+ code : string ,
136272 framework : ComponentFramework ,
137- ) : ScannedComponent [ ] => [
138- { name : nameFromFile ( filePath ) , filePath, exportKind : 'default' , propNames : [ ] , framework } ,
139- ] ;
273+ ) : ScannedComponent [ ] => {
274+ const base = {
275+ name : nameFromFile ( filePath ) ,
276+ filePath,
277+ exportKind : 'default' as const ,
278+ framework,
279+ } ;
280+ const scripts = extractScriptBlocks ( code ) ;
281+ if ( scripts . length === 0 ) return [ { ...base , propNames : [ ] , propsExtracted : true } ] ;
282+
283+ const names = new Set < string > ( ) ;
284+ for ( const script of scripts ) {
285+ let program : ReturnType < typeof parseSync > [ 'program' ] ;
286+ try {
287+ // Name the virtual source so oxc picks the right dialect (TS enables defineProps<...>()).
288+ program = parseSync ( `sfc.${ script . ts ? 'ts' : 'js' } ` , script . body ) . program ;
289+ } catch {
290+ continue ;
291+ }
292+ if ( framework === 'vue' ) {
293+ for ( const call of collectCalls ( program ) )
294+ if ( ( call as { callee ?: { name ?: string } } ) . callee ?. name === 'defineProps' )
295+ for ( const n of definePropsNames ( call ) ) names . add ( n ) ;
296+ for ( const n of vueOptionsPropsNames ( program ) ) names . add ( n ) ;
297+ } else {
298+ for ( const n of sveltePropNames ( program ) ) names . add ( n ) ;
299+ }
300+ }
301+ // Found props → confidently extracted. None found despite a script → unknown (don't claim).
302+ return [ { ...base , propNames : [ ...names ] , propsExtracted : names . size > 0 } ] ;
303+ } ;
140304
141305const frameworkForExt = ( ext : string ) : ComponentFramework | null => {
142306 if ( REACT_EXTS . has ( ext ) ) return 'react' ;
@@ -164,7 +328,7 @@ export const scanComponents = async (
164328
165329 for await ( const entry of glob ( patterns , { cwd : rootDir } ) ) {
166330 const rel = typeof entry === 'string' ? entry : String ( entry ) ;
167- if ( rel . split ( '/' ) . some ( seg => IGNORED_DIRS . has ( seg ) ) ) continue ;
331+ if ( isIgnoredPath ( rel ) ) continue ;
168332 const framework = frameworkForExt ( extname ( rel ) ) ;
169333 if ( framework === null ) continue ;
170334 let code : string ;
@@ -175,7 +339,7 @@ export const scanComponents = async (
175339 continue ;
176340 }
177341 if ( framework === 'react' ) out . push ( ...extractReactComponents ( rel , code ) ) ;
178- else out . push ( ...extractSfcComponent ( rel , framework ) ) ;
342+ else out . push ( ...extractSfcComponent ( rel , code , framework ) ) ;
179343 }
180344 return out ;
181345} ;
0 commit comments