Skip to content

Commit feefada

Browse files
committed
Extract props from flat-barrel .d.ts files (#24), v0.0.34
Bundlers like tsup/rollup often emit a single dist/index.d.ts with bare `interface FooProps {…}` declarations (no `export`) and one export block at the bottom. Strategy 3's types-hydration path already reads pkg.types, but extractPropsFromDts silently failed on this layout. - Make `export` optional in both the exact-name and generic *Props regexes - When regex matches nothing, fall through to the TS AST parser instead of returning empty - Add exactMatchOnly option so the types-hydration path doesn't fall back to "first *Props wins" — a flat barrel .d.ts has many *Props interfaces, and the generic fallback would give every component the same wrong props
1 parent 2c8c8fb commit feefada

5 files changed

Lines changed: 89 additions & 13 deletions

File tree

docs/component-discovery.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,14 @@ When only a bundled dist file is available (no source), the scanner extracts com
173173
export{Zu as alertBanner, lu as base64Editor, Au as box, ...}
174174
```
175175

176-
This provides component names and import paths but no prop definitions. The scanner filters out non-component exports by skipping:
176+
This provides component names and import paths. The scanner filters out non-component exports by skipping:
177177
- `default`, `install`
178178
- Names ending in `Props` or `Emits`
179179
- `ALL_CAPS` constants
180180
- Utility function prefixes (`use*`, `create*`, `get*`, `set*`, `is*`, `has*`, `with*`, `to*`, `from*`)
181181

182+
After extracting names, the scanner hydrates props from the package's declared types file (`pkg.exports["."].types` / `pkg.types` / `pkg.typings`). It looks up the exact `${ComponentName}Props` interface for each discovered component — a flat barrel `.d.ts` typically contains many `*Props` interfaces, so the generic "first-match" fallback is disabled in this path. Interfaces declared without `export` (bundler output from tsup/rollup) are handled by making the `export` keyword optional in both the regex and AST extractors.
183+
182184
## Props extraction
183185

184186
Props are extracted differently depending on the source file type.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "annotask",
3-
"version": "0.0.33",
3+
"version": "0.0.34",
44
"description": "Visual UI design tool for web apps. Make changes in the browser and generate structured reports that AI agents can apply to source code. Works with Vue, React, Svelte, SolidJS, and plain HTML via Vite or Webpack.",
55
"license": "MIT",
66
"type": "module",

src/server/component-scanner.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -860,9 +860,11 @@ async function scanFromEntryPoint(name: string, pkgDir: string, sourceDir?: stri
860860
}
861861
} catch { /* no package.json */ }
862862
if (typesPath) {
863+
// Exact-match only: a flat barrel .d.ts has many *Props interfaces — falling back
864+
// to "first *Props wins" would give every component the same wrong props.
863865
for (const comp of nameOnly) {
864866
if (comp.props.length === 0) {
865-
const dtsResult = await extractPropsFromDts(typesPath, comp.name)
867+
const dtsResult = await extractPropsFromDts(typesPath, comp.name, { exactMatchOnly: true })
866868
comp.props = dtsResult.props
867869
}
868870
}
@@ -994,6 +996,7 @@ async function extractPropsFromPropDefs(filePath: string): Promise<ScannedProp[]
994996
async function extractPropsFromDtsViaTs(
995997
dtsPath: string,
996998
componentName: string,
999+
options: { exactMatchOnly?: boolean } = {},
9971000
): Promise<{ props: ScannedProp[]; resolvedName: string | null }> {
9981001
let ts: typeof import('typescript')
9991002
try {
@@ -1017,7 +1020,7 @@ async function extractPropsFromDtsViaTs(
10171020

10181021
let target = interfaces.get(`${componentName}Props`)
10191022
let resolvedName: string | null = null
1020-
if (!target) {
1023+
if (!target && !options.exactMatchOnly) {
10211024
for (const [name, iface] of interfaces) {
10221025
if (!name.endsWith('Props')) continue
10231026
if (name.includes('PassThrough') || name.includes('MethodOptions')) continue
@@ -1096,20 +1099,25 @@ async function extractPropsFromDtsViaTs(
10961099
return { props, resolvedName }
10971100
}
10981101

1099-
async function extractPropsFromDts(dtsPath: string, componentName: string): Promise<{ props: ScannedProp[]; resolvedName: string | null }> {
1102+
async function extractPropsFromDts(
1103+
dtsPath: string,
1104+
componentName: string,
1105+
options: { exactMatchOnly?: boolean } = {},
1106+
): Promise<{ props: ScannedProp[]; resolvedName: string | null }> {
11001107
let content: string
11011108
try { content = await fsp.readFile(dtsPath, 'utf-8') } catch { return { props: [], resolvedName: null } }
11021109

1103-
// Try exact match first, then search for any *Props interface
1110+
// `export` is optional — bundler outputs (tsup/rollup) often emit `interface FooProps {…}`
1111+
// without `export`, relying on a single `export { … }` block at the bottom of the file.
11041112
let propsInterfaceName = `${componentName}Props`
1105-
let interfaceRegex = new RegExp(`export\\s+(?:declare\\s+)?interface\\s+${propsInterfaceName}(?:<[^>]*>)?\\s*(?:extends\\s+[^{]*)?\\{`)
1113+
let interfaceRegex = new RegExp(`(?:export\\s+)?(?:declare\\s+)?interface\\s+${propsInterfaceName}(?:<[^>]*>)?\\s*(?:extends\\s+[^{]*)?\\{`)
11061114
let match = interfaceRegex.exec(content)
11071115

11081116
let resolvedName: string | null = null
1109-
if (!match) {
1110-
// Find any exported *Props interface (e.g., AutoCompleteProps when dir is "autocomplete")
1111-
// Handle generics: DataTableProps<T = any> extends ...
1112-
const genericRegex = /export\s+(?:declare\s+)?interface\s+(\w+Props)(?:<[^>]*>)?\s*(?:extends\s+[^{]*)?\{/g
1117+
if (!match && !options.exactMatchOnly) {
1118+
// Find any *Props interface (e.g., AutoCompleteProps when dir is "autocomplete").
1119+
// Skipped under exactMatchOnly — a flat barrel .d.ts has many *Props; picking the first is wrong.
1120+
const genericRegex = /(?:export\s+)?(?:declare\s+)?interface\s+(\w+Props)(?:<[^>]*>)?\s*(?:extends\s+[^{]*)?\{/g
11131121
let candidate: RegExpExecArray | null
11141122
while ((candidate = genericRegex.exec(content)) !== null) {
11151123
const name = candidate[1]
@@ -1120,7 +1128,10 @@ async function extractPropsFromDts(dtsPath: string, componentName: string): Prom
11201128
resolvedName = name.replace(/Props$/, '')
11211129
break
11221130
}
1123-
if (!match) return { props: [], resolvedName: null }
1131+
}
1132+
if (!match) {
1133+
// Regex found nothing — AST parser is the last resort (handles exotic declarations the regex misses).
1134+
return extractPropsFromDtsViaTs(dtsPath, componentName, options)
11241135
}
11251136

11261137
// Extract the interface body (track brace depth)
@@ -1179,7 +1190,7 @@ async function extractPropsFromDts(dtsPath: string, componentName: string): Prom
11791190
// Fallback: if regex failed to extract anything (e.g. multi-line types, complex generics),
11801191
// try the TypeScript AST parser. Keeps the fast regex path for the 95% case.
11811192
if (props.length === 0) {
1182-
const astResult = await extractPropsFromDtsViaTs(dtsPath, componentName)
1193+
const astResult = await extractPropsFromDtsViaTs(dtsPath, componentName, options)
11831194
if (astResult.props.length > 0) return astResult
11841195
}
11851196

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Fixture: mimics the single flat `dist/index.d.ts` produced by bundlers like tsup/rollup
2+
// for libraries that publish only `dist/` (no per-component subdirs, no per-component .d.ts).
3+
// Interfaces are declared without `export` — the package has a separate export block elsewhere.
4+
// This is the shape that broke extraction for github.com/kurtstohrer/annotask#24.
5+
6+
/// <reference types="react" />
7+
8+
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
9+
/** Body content of the accordion */
10+
children: React.ReactNode;
11+
/** Any additional classes to apply */
12+
className?: string;
13+
/** Should the accordion be expanded? */
14+
expanded?: boolean;
15+
}
16+
17+
interface AccordionGroupProps {
18+
/** Grouped accordion items */
19+
children: React.ReactNode;
20+
className?: string;
21+
/**
22+
* Allow multiple accordions open at once.
23+
* @defaultValue false
24+
*/
25+
multiselect?: boolean;
26+
onToggle?: (index: number) => void;
27+
fullWidth?: boolean;
28+
}
29+
30+
declare function Accordion({ children, className, expanded, ...props }: AccordionProps): JSX.Element;
31+
declare function AccordionGroup({ children, className, multiselect, onToggle, fullWidth, ...props }: AccordionGroupProps): JSX.Element;
32+
33+
interface AdvancedTableProps<T = unknown> {
34+
rows: T[];
35+
columns: number;
36+
}
37+
38+
declare function AdvancedTable<T>({ rows, columns }: AdvancedTableProps<T>): JSX.Element;
39+
40+
export { Accordion, AccordionGroup, AdvancedTable };

test/component-scanner/scanner.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,29 @@ describe('extractPropsFromDts — regex path', () => {
2424
})
2525
})
2626

27+
describe('extractPropsFromDts — flat barrel .d.ts (issue #24)', () => {
28+
it('extracts props from bare `interface` (no `export`)', async () => {
29+
const { props } = await S.extractPropsFromDts(fixture('flat-barrel.d.ts'), 'Accordion')
30+
// Should resolve AccordionProps specifically, not pick the first *Props blindly.
31+
expect(props.map(p => p.name).sort()).toEqual(['children', 'className', 'expanded'])
32+
const expanded = props.find(p => p.name === 'expanded')
33+
expect(expanded?.required).toBe(false)
34+
expect(expanded?.description).toBe('Should the accordion be expanded?')
35+
})
36+
37+
it('resolves per-component props in a file with many *Props interfaces', async () => {
38+
const { props: groupProps } = await S.extractPropsFromDts(fixture('flat-barrel.d.ts'), 'AccordionGroup', { exactMatchOnly: true })
39+
expect(groupProps.map(p => p.name).sort()).toEqual(['children', 'className', 'fullWidth', 'multiselect', 'onToggle'])
40+
const multi = groupProps.find(p => p.name === 'multiselect')
41+
expect(multi?.default).toBe('false')
42+
})
43+
44+
it('exactMatchOnly returns empty when no matching interface exists', async () => {
45+
const { props } = await S.extractPropsFromDts(fixture('flat-barrel.d.ts'), 'Nonexistent', { exactMatchOnly: true })
46+
expect(props).toEqual([])
47+
})
48+
})
49+
2750
describe('extractPropsFromDtsViaTs — AST fallback', () => {
2851
it('handles multi-line prop types the regex extractor can\'t', async () => {
2952
const { props, resolvedName } = await S.extractPropsFromDtsViaTs(fixture('tricky-interface.d.ts'), 'DataTable')

0 commit comments

Comments
 (0)