@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 +85 -0
- package/package.json +26 -0
- package/scripts/extract.ts +425 -0
- package/scripts/fs.ts +51 -0
- package/scripts/generate.ts +55 -0
- package/scripts/inject.ts +189 -0
- package/scripts/log.ts +35 -0
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
|
+
}
|