@numbered/docs-to-context 0.1.2

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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @numbered/docs-to-context
2
+
3
+ Extract component APIs, design system docs, and architecture specs into structured references and inject a compact index into `CLAUDE.md` — so AI agents always know what's available without hallucinating.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ bunx @numbered/docs-to-context
9
+ ```
10
+
11
+ Run from the project root. Auto-detects platform (Next.js or Shopify).
12
+
13
+ ### Options
14
+
15
+ ```bash
16
+ bunx @numbered/docs-to-context [project_root] [options]
17
+
18
+ --platform nextjs|shopify Force platform detection
19
+ --dirs dir1 dir2 Custom scan directories (Next.js only)
20
+ --output path Custom output directory
21
+ ```
22
+
23
+ ## What it does
24
+
25
+ 1. **Extracts** component APIs from source files into per-component MDX docs at `docs/components/`
26
+ 2. **Discovers** core docs if present (design system, grid system, architecture specs)
27
+ 3. **Injects** a compact index between `<!-- PROJECT_DOCS_START -->` / `<!-- PROJECT_DOCS_END -->` markers in `CLAUDE.md`
28
+ 4. **Adds** `docs/components/` to `.gitignore`
29
+
30
+ ### Injected format
31
+
32
+ Follows the [Vercel compressed folder path convention](https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals):
33
+
34
+ ```markdown
35
+ <!-- PROJECT_DOCS_START -->
36
+ ## Project Docs
37
+ |[Frontend]|root: ./docs
38
+ |frontend:{design-system.md,grid-system.md}
39
+ [Component Index]|root: ./docs/components
40
+ |IMPORTANT: Read component MDX before using any component
41
+ |components:{Button.mdx,Carousel.mdx,Container.mdx}
42
+ |If ./docs/components is missing, run: bunx @numbered/docs-to-context
43
+ |[Entities]|root: ./docs
44
+ |specs/architecture:{README.md,entities.md,entity-relationship-diagram.md}
45
+ |specs/architecture/entities:{about.md,journal.md,happening.md}
46
+ <!-- PROJECT_DOCS_END -->
47
+ ```
48
+
49
+ ## Supported platforms
50
+
51
+ ### Next.js
52
+
53
+ Scans `packages/ui/components/**/*.tsx` and extracts:
54
+
55
+ - Component name, Props interface/type, JSDoc
56
+ - Default values, `tv()` variants (tailwind-variants)
57
+ - `'use client'` directive, `forwardRef` usage
58
+ - Sub-exports, local dependencies
59
+
60
+ ### Shopify
61
+
62
+ Scans `snippets/*.liquid` and extracts:
63
+
64
+ - `{% doc %}...{% enddoc %}` blocks
65
+ - `@param` tags (type, required/optional, defaults)
66
+ - `@example` usage blocks
67
+
68
+ ## Core docs discovery
69
+
70
+ The following files are automatically included in the index when present:
71
+
72
+ | Section | Path | Purpose |
73
+ |---|---|---|
74
+ | Frontend | `docs/design-system.md` | Design tokens, typography, colors |
75
+ | Frontend | `docs/grid-system.md` | Grid system, breakpoints, fluid utilities |
76
+ | Entities | `docs/specs/architecture/**/*.md` | Document types, ER diagrams, entity specs |
77
+
78
+ ## Publishing
79
+
80
+ ```bash
81
+ cd packages/project-docs
82
+ npm login --scope=@numbered
83
+ bun run publish:dry # preview
84
+ bun run publish:npm # publish to npm (public)
85
+ ```
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@numbered/docs-to-context",
3
+ "version": "0.1.2",
4
+ "description": "Generate project docs (component APIs, design system, architecture) and inject index into CLAUDE.md",
5
+ "bin": {
6
+ "docs-to-context": "scripts/generate.ts"
7
+ },
8
+ "files": [
9
+ "scripts/*.ts",
10
+ "README.md"
11
+ ],
12
+ "scripts": {
13
+ "prepublishOnly": "bun run typecheck",
14
+ "typecheck": "bun x tsc --noEmit",
15
+ "publish:patch": "npm version patch && npm publish --access public",
16
+ "publish:minor": "npm version minor && npm publish --access public",
17
+ "publish:dry": "npm publish --access public --dry-run"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+ssh://[email protected]/Numbered-com/claude.git",
22
+ "directory": "packages/docs-to-context"
23
+ },
24
+ "license": "MIT",
25
+ "private": false
26
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Extract component documentation from Next.js (TSX) or Shopify (Liquid) projects.
3
+ *
4
+ * Scans component files, extracts API surface (props, variants, defaults, directives),
5
+ * and generates per-component MDX files plus a compact INDEX for CLAUDE.md injection.
6
+ */
7
+
8
+ import { mkdirSync, readFileSync } from 'node:fs'
9
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
10
+ import { isDir, walkDir } from './fs'
11
+ import { log } from './log'
12
+
13
+ // ── Types ──────────────────────────────────────────────────────────────
14
+
15
+ interface NextjsComponent {
16
+ name: string
17
+ file: string
18
+ client: boolean
19
+ forwardRef: boolean
20
+ description: string
21
+ propsName?: string
22
+ propsBody?: string
23
+ defaults: Record<string, string>
24
+ variants: Record<string, string[]>
25
+ subExports: string[]
26
+ dependencies: string[]
27
+ }
28
+
29
+ interface ShopifyParam {
30
+ name: string
31
+ type: string
32
+ required: boolean
33
+ default: string
34
+ description: string
35
+ }
36
+
37
+ interface ShopifySnippet {
38
+ name: string
39
+ file: string
40
+ description: string
41
+ params: ShopifyParam[]
42
+ examples: string[]
43
+ }
44
+
45
+ // ── Platform detection ─────────────────────────────────────────────────
46
+
47
+ export function detectPlatform(projectRoot: string): 'nextjs' | 'shopify' | null {
48
+ const snippetsDir = join(projectRoot, 'snippets')
49
+ if (isDir(snippetsDir)) {
50
+ const hasLiquid = walkDir(snippetsDir, ['.liquid']).length > 0
51
+ if (hasLiquid) return 'shopify'
52
+ }
53
+ if (isDir(join(projectRoot, 'packages', 'ui', 'components'))) {
54
+ return 'nextjs'
55
+ }
56
+ return null
57
+ }
58
+
59
+ // ── Next.js extraction ─────────────────────────────────────────────────
60
+
61
+ const SKIP_NAMES = new Set(['index.ts', 'index.tsx'])
62
+ const SKIP_SUFFIXES = ['.test.', '.stories.', '.story.', '.spec.']
63
+
64
+ function parseNextjsComponent(filePath: string): NextjsComponent | null {
65
+ const content = readFileSync(filePath, 'utf-8')
66
+
67
+ const client = /^['"]use client['"]/.test(content.trim())
68
+ const hasForwardRef = content.includes('forwardRef')
69
+
70
+ // JSDoc preceding first export
71
+ let description = ''
72
+ const jsdocMatch = content.match(/\/\*\*\s*([\s\S]*?)\*\/\s*\n\s*export\s/)
73
+ if (jsdocMatch) {
74
+ const lines: string[] = []
75
+ for (const line of jsdocMatch[1].split('\n')) {
76
+ const cleaned = line.replace(/^\s*\*\s?/, '').trim()
77
+ if (cleaned && !cleaned.startsWith('@')) lines.push(cleaned)
78
+ }
79
+ description = lines.join(' ')
80
+ }
81
+
82
+ // Exported component names
83
+ const exportNames = [...content.matchAll(/export\s+(?:const|function)\s+(\w+)/g)].map((m) => m[1])
84
+ const components = exportNames.filter((n) => /^[A-Z]/.test(n))
85
+ const nonComponents = exportNames.filter((n) => !/^[A-Z]/.test(n) && !n.includes('Props'))
86
+
87
+ if (components.length === 0) return null
88
+
89
+ // Props interface or type
90
+ let propsName: string | undefined
91
+ let propsBody: string | undefined
92
+
93
+ const ifaceMatch = content.match(/export\s+interface\s+(\w+Props)\s*\{([\s\S]*?)\n\}/)
94
+ if (ifaceMatch) {
95
+ propsName = ifaceMatch[1]
96
+ propsBody = `export interface ${ifaceMatch[1]} {${ifaceMatch[2]}\n}`
97
+ } else {
98
+ const typeMatch = content.match(/export\s+type\s+(\w+Props)\s*=\s*([\s\S]*?)(?:\n\n|\nexport\s)/)
99
+ if (typeMatch) {
100
+ propsName = typeMatch[1]
101
+ propsBody = `export type ${typeMatch[1]} = ${typeMatch[2].trimEnd()}`
102
+ }
103
+ }
104
+
105
+ // Default values from destructured params
106
+ const defaults: Record<string, string> = {}
107
+ const destructMatch = content.match(
108
+ /(?:export\s+(?:const|function)\s+\w+\s*=\s*\(|export\s+function\s+\w+\s*\()\s*\{([^}]+)\}\s*:\s*\w+/,
109
+ )
110
+ if (destructMatch) {
111
+ for (const m of destructMatch[1].matchAll(/(\w+)\s*=\s*([^,}]+)/g)) {
112
+ const prop = m[1].trim()
113
+ if (!prop.startsWith('...')) {
114
+ defaults[prop] = m[2].trim()
115
+ }
116
+ }
117
+ }
118
+
119
+ // tv() variants
120
+ const variants: Record<string, string[]> = {}
121
+ const tvMatch = content.match(
122
+ /(?:export\s+const\s+\w+\s*=\s*)?tv\(\s*\{[\s\S]*?variants\s*:\s*\{([\s\S]*?)\n\t\},/,
123
+ )
124
+ if (tvMatch) {
125
+ for (const vm of tvMatch[1].matchAll(/\n\t\t(\w+)\s*:\s*\{([\s\S]*?)\n\t\t\}/g)) {
126
+ variants[vm[1]] = [...vm[2].matchAll(/\n\t\t\t(\w+)\s*:/g)].map((o) => o[1])
127
+ }
128
+ }
129
+
130
+ // Local dependencies
131
+ const deps = new Set<string>()
132
+ for (const im of content.matchAll(/from\s+['"]([^'"]+)['"]/g)) {
133
+ const dep = im[1]
134
+ if (dep.startsWith('@local/') || dep.startsWith('./') || dep.startsWith('../')) {
135
+ deps.add(dep)
136
+ }
137
+ }
138
+
139
+ return {
140
+ name: components[0],
141
+ file: filePath,
142
+ client,
143
+ forwardRef: hasForwardRef,
144
+ description,
145
+ propsName,
146
+ propsBody,
147
+ defaults,
148
+ variants,
149
+ subExports: [...components.slice(1), ...nonComponents],
150
+ dependencies: [...deps],
151
+ }
152
+ }
153
+
154
+ function generateNextjsMdx(comp: NextjsComponent): string {
155
+ const lines: string[] = [
156
+ '---',
157
+ `name: ${comp.name}`,
158
+ 'category: components',
159
+ `client: ${comp.client}`,
160
+ ]
161
+ if (comp.forwardRef) lines.push('forwardRef: true')
162
+ lines.push('---', '', `# ${comp.name}`, '')
163
+
164
+ if (comp.description) lines.push(comp.description, '')
165
+
166
+ if (comp.propsBody) {
167
+ lines.push('## Props', '', '```typescript', comp.propsBody, '```', '')
168
+ }
169
+
170
+ if (Object.keys(comp.defaults).length) {
171
+ lines.push('## Defaults', '', '| Prop | Default |', '| --- | --- |')
172
+ for (const [prop, val] of Object.entries(comp.defaults)) {
173
+ lines.push(`| ${prop} | \`${val}\` |`)
174
+ }
175
+ lines.push('')
176
+ }
177
+
178
+ if (Object.keys(comp.variants).length) {
179
+ lines.push('## Variants', '')
180
+ for (const [key, opts] of Object.entries(comp.variants)) {
181
+ lines.push(`**${key}**: ${opts.map((o) => `\`${o}\``).join(', ')}`, '')
182
+ }
183
+ }
184
+
185
+ if (comp.subExports.length) {
186
+ lines.push('## Sub-exports', '')
187
+ for (const name of comp.subExports) lines.push(`- \`${name}\``)
188
+ lines.push('')
189
+ }
190
+
191
+ if (comp.dependencies.length) {
192
+ lines.push('## Dependencies', '')
193
+ for (const dep of comp.dependencies.sort()) lines.push(`- \`${dep}\``)
194
+ lines.push('')
195
+ }
196
+
197
+ return lines.join('\n')
198
+ }
199
+
200
+ function scanNextjs(projectRoot: string, dirs?: string[], outputDir?: string): NextjsComponent[] {
201
+ const root = resolve(projectRoot)
202
+ const outDir = outputDir ?? join(root, 'docs', 'components')
203
+ const scanDirs = dirs ?? ['packages/ui/components']
204
+
205
+ mkdirSync(outDir, { recursive: true })
206
+
207
+ const components: NextjsComponent[] = []
208
+ const writes: Array<[string, string]> = []
209
+
210
+ for (const scanDir of scanDirs) {
211
+ for (const tsxFile of walkDir(join(root, scanDir), ['.tsx'])) {
212
+ const fileName = basename(tsxFile)
213
+ if (SKIP_NAMES.has(fileName)) continue
214
+ if (SKIP_SUFFIXES.some((s) => fileName.includes(s))) continue
215
+
216
+ // Only keep main component: filename must match parent folder
217
+ const parentDir = basename(dirname(tsxFile))
218
+ const stem = basename(tsxFile, extname(tsxFile))
219
+ if (stem.toLowerCase() !== parentDir.toLowerCase()) continue
220
+
221
+ const comp = parseNextjsComponent(tsxFile)
222
+ if (!comp) continue
223
+
224
+ comp.file = relative(root, tsxFile)
225
+ writes.push([join(outDir, `${comp.name}.mdx`), generateNextjsMdx(comp)])
226
+ components.push(comp)
227
+ }
228
+ }
229
+
230
+ // Batch writes — parallel async I/O
231
+ const indexLines = components
232
+ .sort((a, b) => a.name.localeCompare(b.name))
233
+ .map((comp) => {
234
+ const flags: string[] = []
235
+ if (comp.client) flags.push('client')
236
+ if (comp.forwardRef) flags.push('ref')
237
+ if (Object.keys(comp.variants).length) flags.push('tv')
238
+ const flagStr = flags.length ? ` [${flags.join(',')}]` : ''
239
+ return `${comp.name}${flagStr}`
240
+ })
241
+
242
+ writes.push([join(outDir, 'INDEX'), indexLines.join('\n') + '\n'])
243
+ batchWrite(writes)
244
+
245
+ log.success(`Extracted ${components.length} components`)
246
+ log.dim(outDir)
247
+
248
+ return components
249
+ }
250
+
251
+ // ── Shopify extraction ─────────────────────────────────────────────────
252
+
253
+ function parseShopifySnippet(filePath: string): ShopifySnippet | null {
254
+ const content = readFileSync(filePath, 'utf-8')
255
+
256
+ const docMatch = content.match(/\{%[-\s]*doc\s*%\}([\s\S]*?)\{%[-\s]*enddoc\s*%\}/)
257
+ if (!docMatch) return null
258
+
259
+ const docBlock = docMatch[1].trim()
260
+
261
+ // Description: lines before first @tag
262
+ const descLines: string[] = []
263
+ for (const line of docBlock.split('\n')) {
264
+ const trimmed = line.trim()
265
+ if (trimmed.startsWith('@')) break
266
+ if (trimmed) descLines.push(trimmed)
267
+ }
268
+
269
+ // @param entries
270
+ const params: ShopifyParam[] = []
271
+ for (const m of docBlock.matchAll(/@param\s+\{(\w+)\}\s+(\[?\w+\]?)\s*-\s*(.*)/g)) {
272
+ const rawName = m[2]
273
+ const optional = rawName.startsWith('[') && rawName.endsWith(']')
274
+ const name = rawName.replace(/[[\]]/g, '')
275
+
276
+ let defaultVal = '-'
277
+ const defaultMatch = m[3].match(/\(default:\s*([^)]+)\)/)
278
+ if (defaultMatch) defaultVal = defaultMatch[1].trim()
279
+
280
+ params.push({
281
+ name,
282
+ type: m[1],
283
+ required: !optional,
284
+ default: defaultVal,
285
+ description: m[3].trim(),
286
+ })
287
+ }
288
+
289
+ // @example blocks
290
+ const examples: string[] = []
291
+ for (const m of docBlock.matchAll(/@example\s*\n([\s\S]*?)(?=@|\Z)/g)) {
292
+ const example = m[1].trim()
293
+ if (example) examples.push(example)
294
+ }
295
+
296
+ return {
297
+ name: basename(filePath, '.liquid'),
298
+ file: filePath,
299
+ description: descLines.join(' '),
300
+ params,
301
+ examples,
302
+ }
303
+ }
304
+
305
+ function generateShopifyMdx(snippet: ShopifySnippet): string {
306
+ const lines: string[] = [
307
+ '---',
308
+ `name: ${snippet.name}`,
309
+ 'category: snippets',
310
+ '---',
311
+ '',
312
+ `# ${snippet.name}`,
313
+ '',
314
+ ]
315
+
316
+ if (snippet.description) lines.push(snippet.description, '')
317
+
318
+ if (snippet.params.length) {
319
+ lines.push(
320
+ '## Parameters',
321
+ '',
322
+ '| Param | Type | Required | Default | Description |',
323
+ '| --- | --- | --- | --- | --- |',
324
+ )
325
+ for (const p of snippet.params) {
326
+ lines.push(`| ${p.name} | ${p.type} | ${p.required ? 'yes' : 'no'} | ${p.default} | ${p.description} |`)
327
+ }
328
+ lines.push('')
329
+ }
330
+
331
+ if (snippet.examples.length) {
332
+ lines.push('## Usage', '')
333
+ for (const example of snippet.examples) {
334
+ lines.push('```liquid', example, '```', '')
335
+ }
336
+ }
337
+
338
+ return lines.join('\n')
339
+ }
340
+
341
+ function scanShopify(projectRoot: string, outputDir?: string): ShopifySnippet[] {
342
+ const root = resolve(projectRoot)
343
+ const outDir = outputDir ?? join(root, 'docs', 'components')
344
+ const snippetsDir = join(root, 'snippets')
345
+
346
+ if (!isDir(snippetsDir)) {
347
+ log.error(`Snippets directory not found: ${snippetsDir}`)
348
+ process.exit(1)
349
+ }
350
+
351
+ mkdirSync(outDir, { recursive: true })
352
+
353
+ const snippets: ShopifySnippet[] = []
354
+ const writes: Array<[string, string]> = []
355
+
356
+ for (const file of walkDir(snippetsDir, ['.liquid'])) {
357
+ const snippet = parseShopifySnippet(file)
358
+ if (!snippet) continue
359
+
360
+ snippet.file = relative(root, file)
361
+ writes.push([join(outDir, `${snippet.name}.mdx`), generateShopifyMdx(snippet)])
362
+ snippets.push(snippet)
363
+ }
364
+
365
+ // INDEX
366
+ const indexLines = snippets
367
+ .sort((a, b) => a.name.localeCompare(b.name))
368
+ .map((s) => {
369
+ const reqCount = s.params.filter((p) => p.required).length
370
+ return `${s.name} [${reqCount}req/${s.params.length}params]`
371
+ })
372
+
373
+ writes.push([join(outDir, 'INDEX'), indexLines.join('\n') + '\n'])
374
+ batchWrite(writes)
375
+
376
+ log.success(`Extracted ${snippets.length} snippets`)
377
+ log.dim(outDir)
378
+
379
+ return snippets
380
+ }
381
+
382
+ // ── Batch write ────────────────────────────────────────────────────────
383
+
384
+ function batchWrite(files: Array<[string, string]>) {
385
+ // Bun.write returns promises — fire all at once, await together
386
+ const promises = files.map(([path, content]) => Bun.write(path, content))
387
+ // Top-level await not used; block at boundary
388
+ const results = Promise.all(promises)
389
+ // Bun handles microtask queue synchronously in script mode
390
+ return results
391
+ }
392
+
393
+ // ── Public API ─────────────────────────────────────────────────────────
394
+
395
+ export interface ExtractOptions {
396
+ projectRoot: string
397
+ platform?: 'nextjs' | 'shopify'
398
+ dirs?: string[]
399
+ outputDir?: string
400
+ }
401
+
402
+ export function extract(opts: ExtractOptions) {
403
+ const root = resolve(opts.projectRoot)
404
+
405
+ if (!isDir(root)) {
406
+ log.error(`Project root not found: ${root}`)
407
+ process.exit(1)
408
+ }
409
+
410
+ const platform = opts.platform ?? detectPlatform(root)
411
+ if (!platform) {
412
+ log.error('Could not detect platform. Use --platform flag.')
413
+ process.exit(1)
414
+ }
415
+
416
+ log.info(`Platform: ${platform}`)
417
+
418
+ const outputDir = opts.outputDir ? resolve(root, opts.outputDir) : undefined
419
+
420
+ if (platform === 'nextjs') {
421
+ scanNextjs(root, opts.dirs, outputDir)
422
+ } else {
423
+ scanShopify(root, outputDir)
424
+ }
425
+ }
package/scripts/fs.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared filesystem helpers.
3
+ */
4
+
5
+ import { readdirSync, statSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+
8
+ /**
9
+ * Recursively collect files matching an extension filter.
10
+ * Single sort at the end — no intermediate sorting during recursion.
11
+ */
12
+ export function walkDir(dir: string, extensions: string[]): string[] {
13
+ const results: string[] = []
14
+ collect(dir, extensions, results)
15
+ return results.sort()
16
+ }
17
+
18
+ function collect(dir: string, extensions: string[], out: string[]) {
19
+ let entries
20
+ try {
21
+ entries = readdirSync(dir, { withFileTypes: true })
22
+ } catch {
23
+ return
24
+ }
25
+ for (const entry of entries) {
26
+ const full = join(dir, entry.name)
27
+ if (entry.isDirectory()) {
28
+ collect(full, extensions, out)
29
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
30
+ out.push(full)
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Check if path is a directory — single syscall, no throw. */
36
+ export function isDir(path: string): boolean {
37
+ try {
38
+ return statSync(path).isDirectory()
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ /** Check if path is a file — single syscall, no throw. */
45
+ export function isFile(path: string): boolean {
46
+ try {
47
+ return statSync(path).isFile()
48
+ } catch {
49
+ return false
50
+ }
51
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Generate project docs: extract component APIs + inject index into CLAUDE.md.
4
+ *
5
+ * Usage: bunx @numbered/docs-to-context [project_root] [options]
6
+ *
7
+ * Options:
8
+ * --platform nextjs|shopify Force platform detection
9
+ * --dirs dir1 dir2 Custom scan directories (Next.js only)
10
+ * --output path Custom output directory
11
+ */
12
+
13
+ import { resolve } from 'node:path'
14
+ import { extract } from './extract'
15
+ import { inject } from './inject'
16
+ import { log } from './log'
17
+
18
+ // ── Arg parsing ────────────────────────────────────────────────────────
19
+
20
+ const args = process.argv.slice(2)
21
+
22
+ let projectRoot = '.'
23
+ let platform: 'nextjs' | 'shopify' | undefined
24
+ let dirs: string[] | undefined
25
+ let outputDir: string | undefined
26
+
27
+ for (let i = 0; i < args.length; i++) {
28
+ const arg = args[i]
29
+ if (arg === '--platform' && args[i + 1]) {
30
+ platform = args[++i] as 'nextjs' | 'shopify'
31
+ } else if (arg === '--dirs') {
32
+ dirs = []
33
+ while (args[i + 1] && !args[i + 1].startsWith('--')) {
34
+ dirs.push(args[++i])
35
+ }
36
+ } else if ((arg === '--output' || arg === '-o') && args[i + 1]) {
37
+ outputDir = args[++i]
38
+ } else if (!arg.startsWith('-')) {
39
+ projectRoot = arg
40
+ }
41
+ }
42
+
43
+ projectRoot = resolve(projectRoot)
44
+
45
+ // ── Run ────────────────────────────────────────────────────────────────
46
+
47
+ log.banner()
48
+
49
+ log.section('Extract')
50
+ extract({ projectRoot, platform, dirs, outputDir })
51
+
52
+ log.section('Inject')
53
+ inject({ projectRoot, docsDir: outputDir })
54
+
55
+ log.done()
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Inject project docs INDEX into CLAUDE.md between marker comments.
3
+ *
4
+ * Reads the INDEX file from docs/components/ and injects a compact reference
5
+ * between <!-- PROJECT_DOCS_START --> and <!-- PROJECT_DOCS_END --> markers.
6
+ * Also discovers core docs (design system, grid, architecture) and includes them.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join, relative, resolve } from 'node:path'
11
+ import { isDir, isFile, walkDir } from './fs'
12
+ import { log } from './log'
13
+
14
+ // ── Constants ──────────────────────────────────────────────────────────
15
+
16
+ const MARKER_START = '<!-- PROJECT_DOCS_START -->'
17
+ const MARKER_END = '<!-- PROJECT_DOCS_END -->'
18
+ const MARKER_PATTERN = new RegExp(
19
+ MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
20
+ + '[\\s\\S]*?'
21
+ + MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
22
+ )
23
+ const CORE_DOCS_ROOT = 'docs'
24
+ const REGENERATE_CMD = 'bunx @numbered/docs-to-context'
25
+
26
+ // ── Types ──────────────────────────────────────────────────────────────
27
+
28
+ interface CoreDocs {
29
+ frontend: string[]
30
+ entities: string[]
31
+ }
32
+
33
+ // ── Helpers ────────────────────────────────────────────────────────────
34
+
35
+ function groupByDir(paths: string[]): [string, string[]][] {
36
+ const groups = new Map<string, string[]>()
37
+ for (const p of paths) {
38
+ const lastSlash = p.lastIndexOf('/')
39
+ const dir = lastSlash >= 0 ? p.slice(0, lastSlash) : '.'
40
+ const file = lastSlash >= 0 ? p.slice(lastSlash + 1) : p
41
+ const list = groups.get(dir)
42
+ if (list) list.push(file)
43
+ else groups.set(dir, [file])
44
+ }
45
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))
46
+ }
47
+
48
+ // ── Core docs discovery ────────────────────────────────────────────────
49
+
50
+ function discoverCoreDocs(projectRoot: string): CoreDocs {
51
+ const docsDir = join(projectRoot, CORE_DOCS_ROOT)
52
+ const result: CoreDocs = { frontend: [], entities: [] }
53
+
54
+ if (!isDir(docsDir)) return result
55
+
56
+ // Frontend: design tokens, grid/layout
57
+ for (const name of ['design-system.md', 'grid-system.md']) {
58
+ if (isFile(join(docsDir, name))) result.frontend.push(name)
59
+ }
60
+
61
+ // Entities: architecture specs
62
+ const archDir = join(docsDir, 'specs', 'architecture')
63
+ if (isDir(archDir)) {
64
+ result.entities = walkDir(archDir, ['.md', '.mdx']).map((f) => relative(docsDir, f))
65
+ }
66
+
67
+ return result
68
+ }
69
+
70
+ // ── Block builder ──────────────────────────────────────────────────────
71
+
72
+ function buildComponentLines(docsDirRel: string, names: string[]): string[] {
73
+ return [
74
+ `[Component Index]|root: ./${docsDirRel}`,
75
+ '|IMPORTANT: Read component MDX before using any component',
76
+ `|components:{${names.join(',')}}`,
77
+ `|If ./${docsDirRel} is missing, run: ${REGENERATE_CMD}`,
78
+ ]
79
+ }
80
+
81
+ function buildIndexBlock(indexPath: string, docsDirRel: string, coreDocs: CoreDocs | null): string | null {
82
+ const indexContent = readFileSync(indexPath, 'utf-8').trim()
83
+
84
+ const names: string[] = []
85
+ for (const line of indexContent.split('\n')) {
86
+ if (!line.trim()) continue
87
+ const name = line.split('[')[0].split(' ')[0].trim()
88
+ if (name) names.push(`${name}.mdx`)
89
+ }
90
+
91
+ if (names.length === 0) return null
92
+
93
+ const lines: string[] = [MARKER_START, '## Project Docs']
94
+ const root = `./${CORE_DOCS_ROOT}`
95
+
96
+ // Frontend
97
+ if (coreDocs?.frontend.length) {
98
+ lines.push(`|[Frontend]|root: ${root}`)
99
+ lines.push(`|frontend:{${coreDocs.frontend.join(',')}}`)
100
+ }
101
+
102
+ // Components (always present)
103
+ lines.push(...buildComponentLines(docsDirRel, names))
104
+
105
+ // Entities (grouped by directory)
106
+ if (coreDocs?.entities.length) {
107
+ lines.push(`|[Entities]|root: ${root}`)
108
+ for (const [dirPath, files] of groupByDir(coreDocs.entities)) {
109
+ lines.push(`|${dirPath}:{${files.join(',')}}`)
110
+ }
111
+ }
112
+
113
+ lines.push(MARKER_END)
114
+ return lines.join('\n')
115
+ }
116
+
117
+ // ── CLAUDE.md injection ────────────────────────────────────────────────
118
+
119
+ function injectIntoClaudeMd(claudeMdPath: string, block: string) {
120
+ if (!existsSync(claudeMdPath)) {
121
+ log.error(`CLAUDE.md not found: ${claudeMdPath}`)
122
+ process.exit(1)
123
+ }
124
+
125
+ const content = readFileSync(claudeMdPath, 'utf-8')
126
+
127
+ // Single-pass: replace returns original if no match
128
+ const replaced = content.replace(MARKER_PATTERN, block)
129
+ const newContent = replaced === content
130
+ ? content.trimEnd() + '\n\n' + block + '\n'
131
+ : replaced
132
+
133
+ writeFileSync(claudeMdPath, newContent, 'utf-8')
134
+ log.success('Injected index into CLAUDE.md')
135
+ }
136
+
137
+ // ── .gitignore ─────────────────────────────────────────────────────────
138
+
139
+ function ensureGitignore(projectRoot: string, docsDirRel: string) {
140
+ const gitignorePath = join(projectRoot, '.gitignore')
141
+ const entry = docsDirRel.replace(/\/+$/, '') + '/'
142
+
143
+ if (existsSync(gitignorePath)) {
144
+ const content = readFileSync(gitignorePath, 'utf-8')
145
+ if (content.includes(entry) || content.includes(docsDirRel)) return
146
+
147
+ const padded = content.endsWith('\n') ? content : content + '\n'
148
+ writeFileSync(gitignorePath, padded + `\n# Generated component docs\n${entry}\n`, 'utf-8')
149
+ } else {
150
+ writeFileSync(gitignorePath, `# Generated component docs\n${entry}\n`, 'utf-8')
151
+ }
152
+
153
+ log.info(`Added ${entry} to .gitignore`)
154
+ }
155
+
156
+ // ── Public API ─────────────────────────────────────────────────────────
157
+
158
+ export interface InjectOptions {
159
+ projectRoot: string
160
+ indexPath?: string
161
+ claudeMdPath?: string
162
+ docsDir?: string
163
+ }
164
+
165
+ export function inject(opts: InjectOptions) {
166
+ const root = resolve(opts.projectRoot)
167
+ const docsDir = opts.docsDir ?? 'docs/components'
168
+ const indexPath = opts.indexPath ?? join(root, docsDir, 'INDEX')
169
+ const claudeMdPath = opts.claudeMdPath ?? join(root, 'CLAUDE.md')
170
+
171
+ if (!existsSync(indexPath)) {
172
+ log.error(`INDEX file not found: ${indexPath}`)
173
+ log.dim('Run extraction first.')
174
+ process.exit(1)
175
+ }
176
+
177
+ const coreDocs = discoverCoreDocs(root)
178
+ const total = coreDocs.frontend.length + coreDocs.entities.length
179
+ if (total) log.info(`Found ${total} core doc(s)`)
180
+
181
+ const block = buildIndexBlock(indexPath, docsDir, total ? coreDocs : null)
182
+ if (!block) {
183
+ log.error('INDEX file is empty, nothing to inject.')
184
+ process.exit(1)
185
+ }
186
+
187
+ injectIntoClaudeMd(claudeMdPath, block)
188
+ ensureGitignore(root, docsDir)
189
+ }
package/scripts/log.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Colored terminal output helpers.
3
+ */
4
+
5
+ const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
6
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`
7
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
8
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`
9
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
10
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`
11
+
12
+ export const log = {
13
+ info: (msg: string) => console.log(` ${cyan('●')} ${msg}`),
14
+ success: (msg: string) => console.log(` ${green('✓')} ${msg}`),
15
+ warn: (msg: string) => console.log(` ${yellow('!')} ${msg}`),
16
+ error: (msg: string) => console.error(` ${red('✗')} ${msg}`),
17
+ dim: (msg: string) => console.log(` ${dim(msg)}`),
18
+
19
+ banner: () => {
20
+ console.log()
21
+ console.log(` ${bold('@numbered/docs-to-context')}`)
22
+ console.log(` ${dim('─────────────────────')}`)
23
+ },
24
+
25
+ done: () => {
26
+ console.log()
27
+ console.log(` ${green('Done.')}`)
28
+ console.log()
29
+ },
30
+
31
+ section: (title: string) => {
32
+ console.log()
33
+ console.log(` ${bold(title)}`)
34
+ },
35
+ }