@kolkrabbi/kol-workshop 0.1.0

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.
@@ -0,0 +1,417 @@
1
+ import { useContext, useEffect, useMemo, useState } from 'react'
2
+ import { useParams, Link, useNavigate } from 'react-router-dom'
3
+ import { CodeBlock, Divider, DocsToc, Icon, Tag } from '@kolkrabbi/kol-component'
4
+ import { ShellTocContext } from '../shell'
5
+ import { useTagMode } from '../tags'
6
+ import { parseDocsMarkdown, isIndexFile, getTagColor } from '../engine'
7
+ import DocsHeader from './DocsHeader.jsx'
8
+ import DocsArticle from './DocsArticle.jsx'
9
+ import DocsFrontmatter from './DocsFrontmatter.jsx'
10
+ import { renderInlineTokens } from './render-tokens.jsx'
11
+
12
+ const SidebarSection = ({ sectionKey, label, collapsedSections, toggleSection, children }) => (
13
+ <div>
14
+ <button
15
+ type="button"
16
+ className="shell-sidebar-toggle shell-sidebar-label"
17
+ onClick={() => toggleSection(sectionKey)}
18
+ >
19
+ {label}
20
+ </button>
21
+ {!collapsedSections[sectionKey] && children}
22
+ </div>
23
+ )
24
+
25
+ const DocReaderSidebar = ({ toc, allTags, docId, docsIndexHref, componentsHref, docFilePath }) => {
26
+ const navigate = useNavigate()
27
+ const { openTagMode } = useTagMode()
28
+ const [collapsedSections, setCollapsedSections] = useState({})
29
+ const toggleSection = (key) => setCollapsedSections(prev => ({ ...prev, [key]: !prev[key] }))
30
+
31
+ return (
32
+ <div className="space-y-6">
33
+ <SidebarSection
34
+ sectionKey="toc"
35
+ label="On this page"
36
+ collapsedSections={collapsedSections}
37
+ toggleSection={toggleSection}
38
+ >
39
+ <DocsToc toc={toc} />
40
+ </SidebarSection>
41
+
42
+ <SidebarSection
43
+ sectionKey="actions"
44
+ label="Quick actions"
45
+ collapsedSections={collapsedSections}
46
+ toggleSection={toggleSection}
47
+ >
48
+ <div className="space-y-1">
49
+ <button
50
+ type="button"
51
+ className="shell-sidebar-action"
52
+ onClick={() => navigate(-1)}
53
+ >
54
+ <Icon name="arrow-left" size={14} />
55
+ Back
56
+ </button>
57
+ <Link to={docsIndexHref} className="shell-sidebar-action">
58
+ <Icon name="book-open" size={14} />
59
+ All documentation
60
+ </Link>
61
+ <Link to={componentsHref} className="shell-sidebar-action">
62
+ <Icon name="grid" size={14} />
63
+ View components
64
+ </Link>
65
+ <button
66
+ type="button"
67
+ className="shell-sidebar-action"
68
+ onClick={() => navigator.clipboard.writeText(docFilePath(docId))}
69
+ title="Copy file path to clipboard"
70
+ >
71
+ <Icon name="copy" size={14} />
72
+ Copy path
73
+ </button>
74
+ </div>
75
+ </SidebarSection>
76
+
77
+ {allTags.length > 0 && (
78
+ <SidebarSection
79
+ sectionKey="tags"
80
+ label="Tags"
81
+ collapsedSections={collapsedSections}
82
+ toggleSection={toggleSection}
83
+ >
84
+ <div className="flex flex-col gap-1.5 items-start min-w-0 w-full">
85
+ {allTags.map((tag) => (
86
+ <Tag
87
+ key={tag}
88
+ variant="naked"
89
+ size="lg"
90
+ color={getTagColor(tag)}
91
+ onClick={() => openTagMode(tag)}
92
+ className="max-w-full overflow-hidden text-ellipsis"
93
+ >
94
+ {tag}
95
+ </Tag>
96
+ ))}
97
+ </div>
98
+ </SidebarSection>
99
+ )}
100
+ </div>
101
+ )
102
+ }
103
+
104
+ /**
105
+ * DocumentationReader — the docs render surface, decoupled from Vite.
106
+ *
107
+ * The monorepo original re-declared its own `import.meta.glob('@docs/…')` and
108
+ * hardcoded `/workshop/*` routes. Both are lifted OUT here: the raw markdown
109
+ * modules + the inventory arrive as props, and every route is derived from
110
+ * `docHref` / `routes`, so this package stays bundler- and route-agnostic.
111
+ *
112
+ * @param {Object} props
113
+ * @param {Array} props.inventory Doc inventory (was `documentationInventory`).
114
+ * @param {Object} props.modules Map of `path -> raw markdown string` (was the
115
+ * `import.meta.glob` result). The consuming app
116
+ * supplies its own glob.
117
+ * @param {Function} props.docHref (id) => route for a single doc. Default
118
+ * `(id) => \`/docs/${id}\``.
119
+ * @param {Object} props.routes Config for the remaining routes/paths:
120
+ * @param {string} routes.docsIndex "all documentation" link (default `/docs`).
121
+ * @param {string} routes.components "view components" link (default `/components`).
122
+ * @param {Function} routes.tagHref (tag) => href for hashtag pills
123
+ * (default `/docs?tag=…`).
124
+ * @param {Function} routes.docFilePath (id) => on-disk path copied to clipboard
125
+ * (default `docs/documentation/${id}.md`).
126
+ */
127
+ const DocumentationReader = ({
128
+ inventory = [],
129
+ modules = {},
130
+ docHref = (id) => `/docs/${id}`,
131
+ routes = {}
132
+ }) => {
133
+ const {
134
+ docsIndex = '/docs',
135
+ components = '/components',
136
+ tagHref = (tag) => `/docs?tag=${encodeURIComponent(tag)}`,
137
+ docFilePath = (id) => `docs/documentation/${id}.md`
138
+ } = routes
139
+
140
+ const { docId } = useParams()
141
+ const setTocContent = useContext(ShellTocContext)
142
+
143
+ // Build a Set of known doc IDs for fast lookup
144
+ const knownDocIds = useMemo(
145
+ () => new Set(inventory.map((d) => d.id)),
146
+ [inventory]
147
+ )
148
+
149
+ /**
150
+ * Resolve a .md link URL to an app route, or null if not a known doc.
151
+ * Extracts the doc ID from the filename portion of the URL.
152
+ */
153
+ const resolveDocLink = (url) => {
154
+ if (!url || !url.includes('.md')) return null
155
+ // Strip anchor fragment
156
+ const [pathPart, anchor] = url.split('#')
157
+ const basename = pathPart.split('/').pop().replace(/\.md$/, '')
158
+ if (knownDocIds.has(basename)) {
159
+ const route = docHref(basename)
160
+ return anchor ? `${route}#${anchor}` : route
161
+ }
162
+ return null
163
+ }
164
+
165
+ // Bind the resolved link + tag helpers so call sites stay terse.
166
+ const renderTokens = (tokens, tokenKey) =>
167
+ renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref)
168
+
169
+ const doc = useMemo(() => {
170
+ return inventory.find((d) => d.id === docId)
171
+ }, [inventory, docId])
172
+
173
+ const rawMarkdown = useMemo(() => {
174
+ if (!doc) return null
175
+ // For index files, the docId is like "00-metadata-index" but the file is "index.md"
176
+ // For regular files, docId matches the filename (e.g., "0.0.1-writing-guidelines")
177
+ const path = Object.keys(modules).find((p) => {
178
+ if (isIndexFile(docId)) {
179
+ // Match index.md files by checking if the path ends with /index.md
180
+ // and the parent folder matches the docId prefix
181
+ const folderMatch = docId.match(/^(\d+-[a-z-]+)-index$/)
182
+ const nestedMatch = docId.match(/^([a-z]+)-index$/)
183
+ if (folderMatch) {
184
+ // e.g., "00-metadata-index" → look for "00-metadata/index.md"
185
+ return p.includes(`/${folderMatch[1]}/index.md`)
186
+ } else if (nestedMatch) {
187
+ // e.g., "foundry-index" → look for "foundry/index.md"
188
+ return p.includes(`/${nestedMatch[1]}/index.md`)
189
+ }
190
+ }
191
+ return p.endsWith(`${docId}.md`)
192
+ })
193
+ return path ? modules[path] : null
194
+ }, [doc, docId, modules])
195
+
196
+ const { sections, toc, introBlocks, inlineTags } = useMemo(() => {
197
+ if (!rawMarkdown) return { sections: [], toc: [], introBlocks: [], inlineTags: [] }
198
+ const parsed = parseDocsMarkdown(rawMarkdown)
199
+ return { sections: parsed.sections, toc: parsed.toc, introBlocks: parsed.introBlocks, inlineTags: parsed.inlineTags }
200
+ }, [rawMarkdown])
201
+
202
+ // Combine frontmatter tags with inline hashtags
203
+ const allTags = useMemo(() => {
204
+ const frontmatterTags = doc?.metadata?.tags || []
205
+ return [...new Set([...frontmatterTags, ...inlineTags])]
206
+ }, [doc, inlineTags])
207
+
208
+ // Extract H1 title from introBlocks
209
+ const docTitle = useMemo(() => {
210
+ const h1Block = introBlocks.find((block) => block.type === 'heading1')
211
+ return h1Block?.content || null
212
+ }, [introBlocks])
213
+
214
+ useEffect(() => {
215
+ setTocContent(
216
+ <DocReaderSidebar
217
+ key={docId}
218
+ toc={toc}
219
+ allTags={allTags}
220
+ docId={docId}
221
+ docsIndexHref={docsIndex}
222
+ componentsHref={components}
223
+ docFilePath={docFilePath}
224
+ />
225
+ )
226
+ return () => setTocContent(null)
227
+ }, [setTocContent, docId, toc, allTags, docsIndex, components, docFilePath])
228
+
229
+ if (!doc) {
230
+ return (
231
+ <div className="max-w-[1400px] mx-auto px-10 py-16">
232
+ <DocsHeader title="Document Not Found" subtitle={`Could not find document: ${docId}`} />
233
+ <p className="kol-mono-12 mt-6">
234
+ <Link to={docsIndex} className="text-accent-primary">
235
+ ← Back to documentation
236
+ </Link>
237
+ </p>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ return (
243
+ <DocsArticle>
244
+ <DocsFrontmatter metadata={doc.metadata} docId={docId} />
245
+ {docTitle && (
246
+ <h1 className="docs-title">{docTitle}</h1>
247
+ )}
248
+ {/* Render intro blocks (excluding H1 which is docTitle) */}
249
+ {introBlocks.filter(b => b.type !== 'heading1').map((block, index) => {
250
+ const blockKey = `intro-${block.type}-${index}`
251
+ switch (block.type) {
252
+ case 'paragraph':
253
+ return (
254
+ <p key={blockKey}>
255
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
256
+ </p>
257
+ )
258
+ case 'heading3':
259
+ return (
260
+ <h3 key={blockKey} id={block.id}>
261
+ {block.content}
262
+ </h3>
263
+ )
264
+ case 'heading4':
265
+ return (
266
+ <h4 key={blockKey} id={block.id}>
267
+ {block.content}
268
+ </h4>
269
+ )
270
+ case 'list':
271
+ return block.items ? (
272
+ <ul key={blockKey} className="docs-list tight">
273
+ {block.items.map((item, i) => (
274
+ <li key={i}>
275
+ {item.tokens ? renderTokens(item.tokens, `${blockKey}-item-${i}`) : item.content}
276
+ </li>
277
+ ))}
278
+ </ul>
279
+ ) : null
280
+ case 'blockquote':
281
+ return (
282
+ <blockquote key={blockKey} className="docs-callout">
283
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
284
+ </blockquote>
285
+ )
286
+ case 'divider':
287
+ return <Divider key={blockKey} className="docs-divider" opacity="12" />
288
+ case 'code':
289
+ return (
290
+ <CodeBlock
291
+ key={blockKey}
292
+ code={block.lines.join('\n')}
293
+ language={block.lang}
294
+ />
295
+ )
296
+ case 'table':
297
+ return (
298
+ <div key={blockKey} className="kol-table-wrapper kol-table--simple">
299
+ <table className="kol-table">
300
+ <thead className="kol-table-thead">
301
+ <tr>
302
+ {block.headers.map((header, headerIndex) => (
303
+ <th key={headerIndex} className="kol-table-cell-title">{header}</th>
304
+ ))}
305
+ </tr>
306
+ </thead>
307
+ <tbody>
308
+ {block.rows.map((row, rowIndex) => (
309
+ <tr key={rowIndex} className="kol-table-row">
310
+ {row.map((cell, cellIndex) => (
311
+ <td key={cellIndex} className="kol-table-cell-text">
312
+ {cell.tokens ? renderTokens(cell.tokens, `${blockKey}-row-${rowIndex}-cell-${cellIndex}`) : cell.content}
313
+ </td>
314
+ ))}
315
+ </tr>
316
+ ))}
317
+ </tbody>
318
+ </table>
319
+ </div>
320
+ )
321
+ default:
322
+ return null
323
+ }
324
+ })}
325
+
326
+ {sections.map(({ heading, id, blocks }) => (
327
+ <section key={id} id={id} className="space-y-6 scroll-mt-32">
328
+ <h2>{heading}</h2>
329
+ {blocks.map((block, index) => {
330
+ const blockKey = `${id}-${block.type}-${index}`
331
+
332
+ switch (block.type) {
333
+ case 'heading3':
334
+ return (
335
+ <h3 key={blockKey} id={block.id}>
336
+ {block.content}
337
+ </h3>
338
+ )
339
+ case 'heading4':
340
+ return (
341
+ <h4 key={blockKey} id={block.id}>
342
+ {block.content}
343
+ </h4>
344
+ )
345
+ case 'paragraph':
346
+ return (
347
+ <p key={blockKey}>
348
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
349
+ </p>
350
+ )
351
+ case 'list': {
352
+ const listClass = block.ordered
353
+ ? 'docs-list docs-list--ordered tight'
354
+ : 'docs-list tight'
355
+ const ListComponent = block.ordered ? 'ol' : 'ul'
356
+ return (
357
+ <ListComponent key={blockKey} className={listClass}>
358
+ {block.items.map((item, itemIndex) => (
359
+ <li key={itemIndex}>
360
+ {item.tokens ? renderTokens(item.tokens, `${blockKey}-item-${itemIndex}`) : item.content || item}
361
+ </li>
362
+ ))}
363
+ </ListComponent>
364
+ )
365
+ }
366
+ case 'code':
367
+ return (
368
+ <CodeBlock
369
+ key={blockKey}
370
+ code={block.lines.join('\n')}
371
+ language={block.lang}
372
+ />
373
+ )
374
+ case 'blockquote':
375
+ return (
376
+ <blockquote key={blockKey} className="docs-callout">
377
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
378
+ </blockquote>
379
+ )
380
+ case 'divider':
381
+ return <Divider key={blockKey} className="docs-divider" opacity="12" />
382
+ case 'table':
383
+ return (
384
+ <div key={blockKey} className="kol-table-wrapper kol-table--simple">
385
+ <table className="kol-table">
386
+ <thead className="kol-table-thead">
387
+ <tr>
388
+ {block.headers.map((header, headerIndex) => (
389
+ <th key={headerIndex} className="kol-table-cell-title">{header}</th>
390
+ ))}
391
+ </tr>
392
+ </thead>
393
+ <tbody>
394
+ {block.rows.map((row, rowIndex) => (
395
+ <tr key={rowIndex} className="kol-table-row">
396
+ {row.map((cell, cellIndex) => (
397
+ <td key={cellIndex} className="kol-table-cell-text">
398
+ {cell.tokens ? renderTokens(cell.tokens, `${blockKey}-row-${rowIndex}-cell-${cellIndex}`) : cell.content}
399
+ </td>
400
+ ))}
401
+ </tr>
402
+ ))}
403
+ </tbody>
404
+ </table>
405
+ </div>
406
+ )
407
+ default:
408
+ return null
409
+ }
410
+ })}
411
+ </section>
412
+ ))}
413
+ </DocsArticle>
414
+ )
415
+ }
416
+
417
+ export default DocumentationReader
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @kolkrabbi/kol-workshop — docs viewer (render layer).
3
+ *
4
+ * Lifted from the monorepo's `apps/web` docs surface and decoupled from Vite:
5
+ * the reader takes its markdown `modules` + `inventory` as props and derives all
6
+ * routes from `docHref` / `routes` (no `import.meta.glob`, no hardcoded
7
+ * `/workshop/*` paths). The pure parser lives in `../engine`; tag mode in
8
+ * `../tags`; the shell TOC injection context in `../shell`.
9
+ */
10
+ export { default as DocumentationReader } from './DocumentationReader.jsx'
11
+ export { default as DocsArticle } from './DocsArticle.jsx'
12
+ export { default as DocsHeader } from './DocsHeader.jsx'
13
+ export { default as DocsFrontmatter } from './DocsFrontmatter.jsx'
14
+ export { renderInlineTokens } from './render-tokens.jsx'
@@ -0,0 +1,98 @@
1
+ import { Link } from 'react-router-dom'
2
+
3
+ /**
4
+ * React render layer for inline markdown tokens.
5
+ *
6
+ * The PURE token producer (`processInlineMarkdown` / `parseDocsMarkdown`) lives
7
+ * in `../engine/parse-markdown.js` with zero React/router deps. This module is
8
+ * the render half that was split off from it — it needs react-router `Link`, so
9
+ * it can't live in the engine.
10
+ *
11
+ * @param {Array} tokens Inline token array from parseDocsMarkdown.
12
+ * @param {string} key Key prefix for the emitted React nodes.
13
+ * @param {Function} resolveDocLink (url) => route|null for `.md` cross-links.
14
+ * @param {Function} tagHref (tag) => href for `#hashtag` pills. Default
15
+ * keeps hashtags route-agnostic (`/docs?tag=…`);
16
+ * the reader passes a configured href in.
17
+ */
18
+ export const renderInlineTokens = (
19
+ tokens,
20
+ key = '',
21
+ resolveDocLink = null,
22
+ tagHref = (tag) => `/docs?tag=${encodeURIComponent(tag)}`
23
+ ) => {
24
+ if (!Array.isArray(tokens)) return null
25
+
26
+ return tokens.map((token, index) => {
27
+ const tokenKey = `${key}-${index}`
28
+
29
+ switch (token.type) {
30
+ case 'text':
31
+ return token.content
32
+
33
+ case 'bold':
34
+ return <strong key={tokenKey}>{token.content}</strong>
35
+
36
+ case 'italic':
37
+ return <em key={tokenKey}>{token.content}</em>
38
+
39
+ case 'code':
40
+ return <code key={tokenKey}>{token.content}</code>
41
+
42
+ case 'link': {
43
+ // For .md links, try to resolve to an app route
44
+ if (resolveDocLink && token.url.includes('.md')) {
45
+ const route = resolveDocLink(token.url)
46
+ if (route) {
47
+ return (
48
+ <Link key={tokenKey} to={route} className="docs-link">
49
+ {token.text}
50
+ </Link>
51
+ )
52
+ }
53
+ // Dead .md link — render as plain text
54
+ return token.text
55
+ }
56
+ return (
57
+ <a key={tokenKey} href={token.url} className="docs-link">
58
+ {token.text}
59
+ </a>
60
+ )
61
+ }
62
+
63
+ case 'image':
64
+ return (
65
+ <img
66
+ key={tokenKey}
67
+ src={token.src}
68
+ alt={token.alt}
69
+ className="docs-image"
70
+ />
71
+ )
72
+
73
+ case 'colorswatch':
74
+ return (
75
+ <code key={tokenKey} className="docs-color-swatch">
76
+ <span className="docs-color-swatch-dot" style={{ background: token.color }} />
77
+ {token.color}
78
+ </code>
79
+ )
80
+
81
+ case 'hashtag':
82
+ return (
83
+ <Link
84
+ key={tokenKey}
85
+ to={tagHref(token.tag)}
86
+ className="inline-tag-pill"
87
+ >
88
+ #{token.tag}
89
+ </Link>
90
+ )
91
+
92
+ default:
93
+ return null
94
+ }
95
+ })
96
+ }
97
+
98
+ export default renderInlineTokens
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Runnable self-check for the pure engine. `node src/engine/__check.mjs`
3
+ * Asserts the parser, frontmatter, inventory seam, and helpers hold.
4
+ */
5
+ import assert from 'node:assert'
6
+ import {
7
+ parseDocsMarkdown,
8
+ parseFrontmatter,
9
+ buildInventory,
10
+ buildInventoryCounts,
11
+ getTagColor,
12
+ extractDocNumber,
13
+ } from './index.js'
14
+
15
+ const md = `---
16
+ title: Demo
17
+ status: draft
18
+ tags: [alpha, beta]
19
+ ---
20
+ # Title
21
+ Intro paragraph with a #gamma tag.
22
+ ## Section One
23
+ - item one
24
+ - item two
25
+ ## Section Two
26
+ | A | B |
27
+ |---|---|
28
+ | 1 | 2 |
29
+ `
30
+
31
+ // --- parser ---
32
+ const parsed = parseDocsMarkdown(md)
33
+ assert.equal(parsed.toc.length, 2, 'two H2 sections in toc')
34
+ assert.equal(parsed.toc[0].id, 'section-one')
35
+ assert.ok(parsed.inlineTags.includes('gamma'), 'harvests inline #gamma')
36
+ assert.equal(parsed.introBlocks[0].type, 'heading1')
37
+ const s1 = parsed.sections[0]
38
+ assert.equal(s1.blocks[0].type, 'list')
39
+ assert.equal(s1.blocks[0].items.length, 2)
40
+ const s2 = parsed.sections[1]
41
+ assert.equal(s2.blocks[0].type, 'table')
42
+ assert.deepEqual(s2.blocks[0].headers, ['A', 'B'])
43
+ assert.equal(s2.blocks[0].rows.length, 1)
44
+
45
+ // --- frontmatter ---
46
+ const fm = parseFrontmatter(md)
47
+ assert.deepEqual(fm.tags, ['alpha', 'beta'], 'inline array tags')
48
+ assert.equal(fm.title, 'Demo')
49
+ assert.equal(fm.status, 'draft')
50
+
51
+ // --- inventory seam (module map, no Vite glob) ---
52
+ const inv = buildInventory({
53
+ '/x/docs/documentation/03-components/3.0.1-button.md': '---\ntags: [ui]\nstatus: stable\n---\n# Button\n',
54
+ '/x/docs/documentation/00-docs/index.md': '---\nstatus: stable\n---\n# Intro\n',
55
+ })
56
+ assert.equal(inv.length, 2)
57
+ const btn = inv.find((d) => d.id === '3.0.1-button')
58
+ assert.ok(btn, 'button doc discovered')
59
+ assert.equal(btn.title, 'Button')
60
+ assert.deepEqual(btn.metadata.tags, ['ui'])
61
+ assert.equal(inv.find((d) => d.file.endsWith('00-docs/index.md')).id, '00-docs-index', 'index.md id gets folder prefix')
62
+ const counts = buildInventoryCounts(inv)
63
+ assert.equal(counts.total, 2)
64
+ assert.equal(counts.statuses.stable, 2)
65
+
66
+ // --- helpers ---
67
+ assert.equal(extractDocNumber('0.0.1-writing'), '0.0.1')
68
+ assert.equal(extractDocNumber('04-pages-index'), '4.0.0')
69
+ assert.ok(['blue', 'teal', 'green', 'yellow', 'red', 'orange', 'purple', 'dark'].includes(getTagColor('ui')))
70
+ assert.equal(getTagColor('ui'), getTagColor('ui'), 'deterministic')
71
+
72
+ console.log('kol-workshop engine self-check: OK')
@@ -0,0 +1,62 @@
1
+ import { parseFrontmatter } from './frontmatter.js'
2
+
3
+ /**
4
+ * The doc inventory + counts, DECOUPLED from Vite.
5
+ *
6
+ * The monorepo original ran `import.meta.glob('@docs/...')` at module load,
7
+ * hard-binding it to Vite and the monorepo `/docs` path. Here the consumer
8
+ * supplies the raw-markdown module map instead — this is the content-injection
9
+ * seam that lets the set drop into any repo:
10
+ *
11
+ * const modules = import.meta.glob('/docs/ ** /*.md',
12
+ * { eager: true, query: '?raw', import: 'default' })
13
+ * const inventory = buildInventory(modules)
14
+ *
15
+ * @param {Record<string,string>} modules path → raw markdown string
16
+ */
17
+ export function buildInventory(modules) {
18
+ const inventory = Object.entries(modules).map(([path, raw]) => {
19
+ const normalisedPath = path.replace(/^.*\/docs\/documentation\//, 'docs/documentation/')
20
+ const lines = raw.split(/\r?\n/)
21
+ const headingLine = lines.find((line) => line.startsWith('# ')) ?? ''
22
+ const title = headingLine.replace(/^#\s+/, '').trim()
23
+ const metadata = parseFrontmatter(raw)
24
+ const filename = normalisedPath.split('/').pop() ?? ''
25
+ const parentFolder = normalisedPath.split('/').slice(-2, -1)[0] ?? ''
26
+ const baseId = filename.replace(/\.md$/, '')
27
+
28
+ // Make index.md IDs unique by prefixing with parent folder
29
+ const id = baseId === 'index' ? `${parentFolder}-index` : baseId
30
+
31
+ const rawTitle = title || metadata.title || id
32
+ const cleanTitle = rawTitle
33
+ .replace(/^Design System\s*[-–—:]\s*/i, '')
34
+ .replace(/\bCheat Sheet\b/i, 'Quicklook')
35
+
36
+ return { id, file: normalisedPath, title: cleanTitle, metadata }
37
+ })
38
+
39
+ inventory.sort((a, b) => a.file.localeCompare(b.file))
40
+ return inventory
41
+ }
42
+
43
+ /** Tally status / category / content-type across an inventory. */
44
+ export function buildInventoryCounts(inventory) {
45
+ return inventory.reduce(
46
+ (acc, doc) => {
47
+ acc.total += 1
48
+ const { metadata } = doc
49
+ if (metadata.status) {
50
+ acc.statuses[metadata.status] = (acc.statuses[metadata.status] ?? 0) + 1
51
+ }
52
+ if (metadata.category) {
53
+ acc.categories[metadata.category] = (acc.categories[metadata.category] ?? 0) + 1
54
+ }
55
+ if (metadata['content-type']) {
56
+ acc.contentTypes[metadata['content-type']] = (acc.contentTypes[metadata['content-type']] ?? 0) + 1
57
+ }
58
+ return acc
59
+ },
60
+ { total: 0, statuses: {}, categories: {}, contentTypes: {} }
61
+ )
62
+ }