@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.
- package/README.md +38 -0
- package/package.json +46 -0
- package/src/compositions/WorkshopDefaultSidebar.jsx +89 -0
- package/src/compositions/WorkshopSidebar.jsx +180 -0
- package/src/compositions/index.js +8 -0
- package/src/docs/DocsArticle.jsx +7 -0
- package/src/docs/DocsFrontmatter.jsx +74 -0
- package/src/docs/DocsHeader.jsx +19 -0
- package/src/docs/DocumentationReader.jsx +417 -0
- package/src/docs/index.js +14 -0
- package/src/docs/render-tokens.jsx +98 -0
- package/src/engine/__check.mjs +72 -0
- package/src/engine/build-inventory.js +62 -0
- package/src/engine/doc-helpers.js +132 -0
- package/src/engine/frontmatter.js +41 -0
- package/src/engine/index.js +20 -0
- package/src/engine/parse-markdown.js +352 -0
- package/src/engine/search.js +36 -0
- package/src/engine/tags.js +75 -0
- package/src/index.js +15 -0
- package/src/shell/ShellLayout.jsx +197 -0
- package/src/shell/ShellSidebar.jsx +146 -0
- package/src/shell/index.js +11 -0
- package/src/tags/TagGraph.jsx +243 -0
- package/src/tags/TagModeContext.jsx +105 -0
- package/src/tags/TagModeGate.jsx +17 -0
- package/src/tags/TagModeOverlay.jsx +172 -0
- package/src/tags/index.js +11 -0
|
@@ -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
|
+
}
|