/** * Extract component documentation from Next.js (TSX) or Shopify (Liquid) projects. * * Scans component files, extracts API surface (props, variants, defaults, directives), * and generates per-component .md files plus a README.md index (conventions + one-line * description per component) that AGENTS.md injection points at. */ import { mkdirSync, readFileSync } from 'node:fs' import { basename, dirname, extname, join, relative, resolve } from 'node:path' import { isDir, isFile, walkDir } from './fs' import { log } from './log' // ── Types ────────────────────────────────────────────────────────────── interface NextjsComponent { name: string file: string client: boolean forwardRef: boolean description: string propsName?: string propsBody?: string refBody?: string defaults: Record variants: Record subExports: string[] dependencies: string[] localDeps: NextjsComponent[] } interface ShopifyParam { name: string type: string required: boolean default: string description: string } interface ShopifySnippet { name: string file: string description: string params: ShopifyParam[] examples: string[] } // ── README index ─────────────────────────────────────────────────────── function firstSentence(text: string): string { const m = text.match(/^.*?[.!?](?=\s|$)/) return (m ? m[0] : text).trim() } function generateReadme(conventions: string[], entries: Array<{ name: string; meta: string; description: string }>): string { const lines = ['# Component Index', '', ...conventions, ''] for (const e of entries) { const meta = e.meta ? ` \`${e.meta}\`` : '' const desc = e.description ? ` — ${firstSentence(e.description)}` : '' lines.push(`- **${e.name}**${meta}${desc}`) } return lines.join('\n') + '\n' } // ── Platform detection ───────────────────────────────────────────────── export function detectPlatform(projectRoot: string): 'nextjs' | 'shopify' | null { const snippetsDir = join(projectRoot, 'snippets') if (isDir(snippetsDir)) { const hasLiquid = walkDir(snippetsDir, ['.liquid']).length > 0 if (hasLiquid) return 'shopify' } if (isDir(join(projectRoot, 'packages', 'ui', 'components'))) { return 'nextjs' } // Non-monorepo Next.js for (const ext of ['.js', '.mjs', '.ts', '.mts']) { if (isFile(join(projectRoot, `next.config${ext}`))) return 'nextjs' } return null } // ── Next.js extraction ───────────────────────────────────────────────── const SKIP_NAMES = new Set(['index.ts', 'index.tsx']) const SKIP_SUFFIXES = ['.test.', '.stories.', '.story.', '.spec.'] function parseNextjsComponent(filePath: string, inlineLocalDeps = true): NextjsComponent | null { const content = readFileSync(filePath, 'utf-8') const client = /^['"]use client['"]/.test(content.trim()) const hasForwardRef = content.includes('forwardRef') // JSDoc directly preceding an export. The body must not cross a `*/` boundary, // or a top-of-file comment (e.g. biome-ignore-all) swallows everything down to // the first real JSDoc'd export. let description = '' const jsdocMatch = content.match(/\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\*\/\s*\n\s*export\s/) if (jsdocMatch) { const lines: string[] = [] for (const line of jsdocMatch[1].split('\n')) { const cleaned = line.replace(/^\s*\*\s?/, '').trim() if (cleaned && !cleaned.startsWith('@') && !cleaned.startsWith('biome-ignore')) lines.push(cleaned) } description = lines.join(' ') } // Exported component names const exportNames = [...content.matchAll(/export\s+(?:const|function)\s+(\w+)/g)].map((m) => m[1]) const components = exportNames.filter((n) => /^[A-Z]/.test(n)) const nonComponents = exportNames.filter((n) => !/^[A-Z]/.test(n) && !n.includes('Props')) if (components.length === 0) return null // Props interface or type (exported or not — local deps often keep theirs private) let propsName: string | undefined let propsBody: string | undefined const ifaceMatch = content.match(/(?:export\s+)?interface\s+(\w+Props)\s*\{[\s\S]*?\n\}/) if (ifaceMatch) { propsName = ifaceMatch[1] propsBody = ifaceMatch[0] } else { const typeMatch = content.match(/export\s+type\s+(\w+Props)\s*=\s*([\s\S]*?)(?:\n\n|\nexport\s)/) if (typeMatch) { propsName = typeMatch[1] propsBody = `export type ${typeMatch[1]} = ${typeMatch[2].trimEnd()}` } } // Imperative ref API (forwardRef components expose it via useImperativeHandle) const refMatch = content.match(/(?:export\s+)?interface\s+(\w+Ref)\s*\{[\s\S]*?\n\}/) const refBody = refMatch?.[0] // Default values from destructured params const defaults: Record = {} const destructMatch = content.match( /(?:export\s+(?:const|function)\s+\w+\s*=\s*\(|export\s+function\s+\w+\s*\()\s*\{([^}]+)\}\s*:\s*\w+/, ) if (destructMatch) { for (const m of destructMatch[1].matchAll(/(\w+)\s*=\s*([^,}]+)/g)) { const prop = m[1].trim() if (!prop.startsWith('...')) { defaults[prop] = m[2].trim() } } } // tv() variants const variants: Record = {} const tvMatch = content.match( /(?:export\s+const\s+\w+\s*=\s*)?tv\(\s*\{[\s\S]*?variants\s*:\s*\{([\s\S]*?)\n\t\},/, ) if (tvMatch) { for (const vm of tvMatch[1].matchAll(/\n\t\t(\w+)\s*:\s*\{([\s\S]*?)\n\t\t\}/g)) { variants[vm[1]] = [...vm[2].matchAll(/\n\t\t\t(\w+)\s*:/g)].map((o) => o[1]) } } // Local dependencies const deps = new Set() for (const im of content.matchAll(/from\s+['"]([^'"]+)['"]/g)) { const dep = im[1] if (dep.startsWith('@local/') || dep.startsWith('./') || dep.startsWith('../')) { deps.add(dep) } } // Local (./) dependencies get their docs inlined — they have no doc file of // their own (filename ≠ parent folder), so the parent doc is their only surface. const localDeps: NextjsComponent[] = [] if (inlineLocalDeps) { for (const dep of deps) { if (!dep.startsWith('./')) continue const base = join(dirname(filePath), dep.slice(2)) const depFile = ['.tsx', '.ts'].map((ext) => `${base}${ext}`).find(isFile) if (!depFile) continue const parsed = parseNextjsComponent(depFile, false) if (parsed) localDeps.push(parsed) } } return { name: components[0], file: filePath, client, forwardRef: hasForwardRef, description, propsName, propsBody, refBody, defaults, variants, subExports: [...components.slice(1), ...nonComponents], dependencies: [...deps], localDeps, } } function generateNextjsMd(comp: NextjsComponent): string { const lines: string[] = [ '---', `name: ${comp.name}`, 'category: components', `client: ${comp.client}`, ] if (comp.forwardRef) lines.push('forwardRef: true') lines.push('---', '', `# ${comp.name}`, '') if (comp.description) lines.push(comp.description, '') if (comp.propsBody) { lines.push('## Props', '', '```typescript', comp.propsBody, '```', '') } if (comp.refBody) { lines.push('## Ref API', '', 'Imperative control via `ref`:', '', '```typescript', comp.refBody, '```', '') } if (Object.keys(comp.defaults).length) { lines.push('## Defaults', '', '| Prop | Default |', '| --- | --- |') for (const [prop, val] of Object.entries(comp.defaults)) { lines.push(`| ${prop} | \`${val}\` |`) } lines.push('') } if (Object.keys(comp.variants).length) { lines.push('## Variants', '') for (const [key, opts] of Object.entries(comp.variants)) { lines.push(`**${key}**: ${opts.map((o) => `\`${o}\``).join(', ')}`, '') } } if (comp.subExports.length) { lines.push('## Sub-exports', '') for (const name of comp.subExports) lines.push(`- \`${name}\``) lines.push('') } if (comp.dependencies.length) { lines.push('## Dependencies', '') const inlined = new Set(comp.localDeps.map((d) => d.name)) const external = comp.dependencies.filter((dep) => !(dep.startsWith('./') && inlined.has(basename(dep)))) for (const dep of external.sort()) lines.push(`- \`${dep}\``) if (external.length) lines.push('') // Local (./) deps have no doc file of their own — inline their API here. for (const dep of comp.localDeps) { lines.push(`### ${dep.name} (internal — \`./${dep.name}\`)`, '') if (dep.description) lines.push(dep.description, '') const code = [dep.propsBody, dep.refBody].filter(Boolean) if (code.length) lines.push('```typescript', code.join('\n\n'), '```', '') } } return lines.join('\n') } function resolveNextjsDirs(root: string): string[] { if (isDir(join(root, 'packages', 'ui', 'components'))) return ['packages/ui/components'] const candidates = ['components', 'src/components'] return candidates.filter((d) => isDir(join(root, d))) } function scanNextjs(projectRoot: string, dirs?: string[], outputDir?: string): NextjsComponent[] { const root = resolve(projectRoot) const outDir = outputDir ?? join(root, '.context', 'components') const scanDirs = dirs ?? resolveNextjsDirs(root) mkdirSync(outDir, { recursive: true }) const components: NextjsComponent[] = [] const writes: Array<[string, string]> = [] for (const scanDir of scanDirs) { for (const tsxFile of walkDir(join(root, scanDir), ['.tsx'])) { const fileName = basename(tsxFile) if (SKIP_NAMES.has(fileName)) continue if (SKIP_SUFFIXES.some((s) => fileName.includes(s))) continue // Only keep main component: filename must match parent folder const parentDir = basename(dirname(tsxFile)) const stem = basename(tsxFile, extname(tsxFile)) if (stem.toLowerCase() !== parentDir.toLowerCase()) continue const comp = parseNextjsComponent(tsxFile) if (!comp) continue comp.file = relative(root, tsxFile) writes.push([join(outDir, `${comp.name}.md`), generateNextjsMd(comp)]) components.push(comp) } } // README.md index — conventions + one-line description per component const entries = components .sort((a, b) => a.name.localeCompare(b.name)) .map((comp) => { const flags: string[] = [] if (comp.client) flags.push('client') if (comp.forwardRef) flags.push('ref') if (Object.keys(comp.variants).length) flags.push('tv') return { name: comp.name, meta: flags.length ? `[${flags.join(',')}]` : '', description: comp.description } }) const readme = generateReadme( [ 'Generated by docs-to-context — do not edit.', 'Read a component\'s `{Name}.md` doc (props, variants, defaults) before using it; never reimplement a pattern this catalog covers.', 'Flags: `client` = client component, `ref` = imperative ref API, `tv` = tailwind-variants.', ], entries, ) writes.push([join(outDir, 'README.md'), readme]) batchWrite(writes) log.success(`Extracted ${components.length} components`) log.dim(outDir) const undescribed = components.filter((c) => !c.description) if (undescribed.length) { log.warn( `${undescribed.length}/${components.length} components lack a description (one-sentence JSDoc above the export): ${undescribed .map((c) => c.name) .join(', ')}`, ) } return components } // ── Shopify extraction ───────────────────────────────────────────────── function parseShopifySnippet(filePath: string): ShopifySnippet | null { const content = readFileSync(filePath, 'utf-8') const docMatch = content.match(/\{%[-\s]*doc\s*%\}([\s\S]*?)\{%[-\s]*enddoc\s*%\}/) if (!docMatch) return null const docBlock = docMatch[1].trim() // Description: lines before first @tag const descLines: string[] = [] for (const line of docBlock.split('\n')) { const trimmed = line.trim() if (trimmed.startsWith('@')) break if (trimmed) descLines.push(trimmed) } // @param entries const params: ShopifyParam[] = [] for (const m of docBlock.matchAll(/@param\s+\{(\w+)\}\s+(\[?\w+\]?)\s*-\s*(.*)/g)) { const rawName = m[2] const optional = rawName.startsWith('[') && rawName.endsWith(']') const name = rawName.replace(/[[\]]/g, '') let defaultVal = '-' const defaultMatch = m[3].match(/\(default:\s*([^)]+)\)/) if (defaultMatch) defaultVal = defaultMatch[1].trim() params.push({ name, type: m[1], required: !optional, default: defaultVal, description: m[3].trim(), }) } // @example blocks const examples: string[] = [] for (const m of docBlock.matchAll(/@example\s*\n([\s\S]*?)(?=@|\Z)/g)) { const example = m[1].trim() if (example) examples.push(example) } return { name: basename(filePath, '.liquid'), file: filePath, description: descLines.join(' '), params, examples, } } function generateShopifyMd(snippet: ShopifySnippet): string { const lines: string[] = [ '---', `name: ${snippet.name}`, 'category: snippets', '---', '', `# ${snippet.name}`, '', ] if (snippet.description) lines.push(snippet.description, '') if (snippet.params.length) { lines.push( '## Parameters', '', '| Param | Type | Required | Default | Description |', '| --- | --- | --- | --- | --- |', ) for (const p of snippet.params) { lines.push(`| ${p.name} | ${p.type} | ${p.required ? 'yes' : 'no'} | ${p.default} | ${p.description} |`) } lines.push('') } if (snippet.examples.length) { lines.push('## Usage', '') for (const example of snippet.examples) { lines.push('```liquid', example, '```', '') } } return lines.join('\n') } function scanShopify(projectRoot: string, outputDir?: string): ShopifySnippet[] { const root = resolve(projectRoot) const outDir = outputDir ?? join(root, '.context', 'components') const snippetsDir = join(root, 'snippets') if (!isDir(snippetsDir)) { log.error(`Snippets directory not found: ${snippetsDir}`) process.exit(1) } mkdirSync(outDir, { recursive: true }) const snippets: ShopifySnippet[] = [] const writes: Array<[string, string]> = [] const skipped: string[] = [] for (const file of walkDir(snippetsDir, ['.liquid'])) { const snippet = parseShopifySnippet(file) if (!snippet) { skipped.push(basename(file)) continue } snippet.file = relative(root, file) writes.push([join(outDir, `${snippet.name}.md`), generateShopifyMd(snippet)]) snippets.push(snippet) } // README.md index — conventions + one-line description per snippet const entries = snippets .sort((a, b) => a.name.localeCompare(b.name)) .map((s) => { const reqCount = s.params.filter((p) => p.required).length return { name: s.name, meta: `[${reqCount}req/${s.params.length}params]`, description: s.description } }) const readme = generateReadme( [ 'Generated by docs-to-context — do not edit.', 'Read a snippet\'s `{name}.md` doc (params, defaults, examples) before rendering it; never reimplement a pattern this catalog covers.', "Render with named params only (`{%- render 'name', param: value -%}`); assign filtered values to variables before passing them.", ], entries, ) writes.push([join(outDir, 'README.md'), readme]) batchWrite(writes) log.success(`Extracted ${snippets.length} snippets`) log.dim(outDir) if (skipped.length) { log.warn(`${skipped.length} snippet(s) skipped — no {% doc %} block: ${skipped.join(', ')}`) } const undescribed = snippets.filter((s) => !s.description) if (undescribed.length) { log.warn( `${undescribed.length}/${snippets.length} snippets lack a description (sentence before the first @tag): ${undescribed .map((s) => s.name) .join(', ')}`, ) } return snippets } // ── Batch write ──────────────────────────────────────────────────────── function batchWrite(files: Array<[string, string]>) { // Bun.write returns promises — fire all at once, await together const promises = files.map(([path, content]) => Bun.write(path, content)) // Top-level await not used; block at boundary const results = Promise.all(promises) // Bun handles microtask queue synchronously in script mode return results } // ── Public API ───────────────────────────────────────────────────────── export interface ExtractOptions { projectRoot: string platform?: 'nextjs' | 'shopify' dirs?: string[] outputDir?: string } export function extract(opts: ExtractOptions) { const root = resolve(opts.projectRoot) if (!isDir(root)) { log.error(`Project root not found: ${root}`) process.exit(1) } const platform = opts.platform ?? detectPlatform(root) if (!platform) { log.warn('Could not detect platform. Available: nextjs, shopify. Use --platform ') return } log.info(`Platform: ${platform}`) const outputDir = opts.outputDir ? resolve(root, opts.outputDir) : undefined if (platform === 'nextjs') { scanNextjs(root, opts.dirs, outputDir) } else { scanShopify(root, outputDir) } }