@kolkrabbi/kol-workshop 0.3.4 → 0.3.6
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL workshop/docs system — a handrolled markdown engine, search, tag system (incl. a d3 tag graph), docs viewer, and docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}; content is consumer-injected (the package never globs docs).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"d3": "^7.9.0",
|
|
16
16
|
"@kolkrabbi/kol-brand": "^0.1.2",
|
|
17
|
-
"@kolkrabbi/kol-icons": "0.8.11",
|
|
18
17
|
"@kolkrabbi/kol-component": "0.14.1",
|
|
19
|
-
"@kolkrabbi/kol-
|
|
20
|
-
"@kolkrabbi/kol-theme": "0.13.
|
|
18
|
+
"@kolkrabbi/kol-icons": "0.8.11",
|
|
19
|
+
"@kolkrabbi/kol-theme": "0.13.3",
|
|
20
|
+
"@kolkrabbi/kol-framework": "0.8.1"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": "^18.3.0 || ^19.0.0",
|
|
@@ -23,7 +23,7 @@ const SidebarSection = ({ sectionKey, label, collapsedSections, toggleSection, c
|
|
|
23
23
|
</div>
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
const DocReaderSidebar = ({ toc, allTags, docId, docsIndexHref, componentsHref, docFilePath }) => {
|
|
26
|
+
const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, componentsHref, docFilePath }) => {
|
|
27
27
|
const navigate = useNavigate()
|
|
28
28
|
const { openTagMode } = useTagMode()
|
|
29
29
|
const [collapsedSections, setCollapsedSections] = useState({})
|
|
@@ -40,6 +40,24 @@ const DocReaderSidebar = ({ toc, allTags, docId, docsIndexHref, componentsHref,
|
|
|
40
40
|
<DocsToc toc={toc} />
|
|
41
41
|
</SidebarSection>
|
|
42
42
|
|
|
43
|
+
{related.length > 0 && (
|
|
44
|
+
<SidebarSection
|
|
45
|
+
sectionKey="related"
|
|
46
|
+
label="Related"
|
|
47
|
+
collapsedSections={collapsedSections}
|
|
48
|
+
toggleSection={toggleSection}
|
|
49
|
+
>
|
|
50
|
+
{/* ONE rail voice (user law): rows = the shell-nav-item idiom. */}
|
|
51
|
+
<nav className="flex flex-col">
|
|
52
|
+
{related.map((r) => (
|
|
53
|
+
<Link key={r.id} to={r.href} className="shell-nav-item block kol-mono-14 text-body transition-colors hover:text-emphasis">
|
|
54
|
+
{r.label}
|
|
55
|
+
</Link>
|
|
56
|
+
))}
|
|
57
|
+
</nav>
|
|
58
|
+
</SidebarSection>
|
|
59
|
+
)}
|
|
60
|
+
|
|
43
61
|
<SidebarSection
|
|
44
62
|
sectionKey="actions"
|
|
45
63
|
label="Quick actions"
|
|
@@ -140,6 +158,7 @@ const DocumentationReader = ({
|
|
|
140
158
|
|
|
141
159
|
const { docId } = useParams()
|
|
142
160
|
const setTocContent = useContext(ShellTocContext)
|
|
161
|
+
const { openTagMode } = useTagMode()
|
|
143
162
|
|
|
144
163
|
// Build a Set of known doc IDs for fast lookup
|
|
145
164
|
const knownDocIds = useMemo(
|
|
@@ -149,23 +168,34 @@ const DocumentationReader = ({
|
|
|
149
168
|
|
|
150
169
|
/**
|
|
151
170
|
* Resolve a .md link URL to an app route, or null if not a known doc.
|
|
152
|
-
* Extracts the doc ID from the filename portion of the URL.
|
|
171
|
+
* Extracts the doc ID from the filename portion of the URL. Bare basenames
|
|
172
|
+
* miss two inventory id dialects (wave-4): index files (`00-overview/INDEX`
|
|
173
|
+
* → id `00-overview-INDEX`) and collision-prefixed ids (`03-components/
|
|
174
|
+
* 01-inventory` → `03-components-01-inventory`) — both gain the parent
|
|
175
|
+
* folder, so try `${parentDir}-${basename}` as the fallback.
|
|
153
176
|
*/
|
|
154
177
|
const resolveDocLink = (url) => {
|
|
155
178
|
if (!url || !url.includes('.md')) return null
|
|
156
179
|
// Strip anchor fragment
|
|
157
180
|
const [pathPart, anchor] = url.split('#')
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
181
|
+
const segs = pathPart.split('/')
|
|
182
|
+
const basename = segs.pop().replace(/\.md$/, '')
|
|
183
|
+
const parentDir = segs.filter((s) => s && s !== '.' && s !== '..').pop()
|
|
184
|
+
const id = knownDocIds.has(basename)
|
|
185
|
+
? basename
|
|
186
|
+
: parentDir && knownDocIds.has(`${parentDir}-${basename}`)
|
|
187
|
+
? `${parentDir}-${basename}`
|
|
188
|
+
: null
|
|
189
|
+
if (!id) return null
|
|
190
|
+
const route = docHref(id)
|
|
191
|
+
return anchor ? `${route}#${anchor}` : route
|
|
164
192
|
}
|
|
165
193
|
|
|
166
|
-
// Bind the resolved link + tag helpers so call sites stay terse.
|
|
194
|
+
// Bind the resolved link + tag helpers so call sites stay terse. Hashtag
|
|
195
|
+
// pills open TAG MODE (wave-4 parity) — the tagHref Link was a dead route
|
|
196
|
+
// in any consumer without a ?tag= index page.
|
|
167
197
|
const renderTokens = (tokens, tokenKey) =>
|
|
168
|
-
renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref)
|
|
198
|
+
renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref, openTagMode)
|
|
169
199
|
|
|
170
200
|
/* ONE block renderer for intro + section content (wave-4 retype, 2026-07-30
|
|
171
201
|
* — the two switches had already drifted). Every element is typed through
|
|
@@ -295,6 +325,35 @@ const DocumentationReader = ({
|
|
|
295
325
|
return h1Block?.content || null
|
|
296
326
|
}, [introBlocks])
|
|
297
327
|
|
|
328
|
+
/* Cross-references (wave-4 parity): frontmatter `related:` entries are
|
|
329
|
+
* wikilinks — `[[target|display]]`, target relative to the doc's own
|
|
330
|
+
* folder. Resolved against the inventory by full path first (handles
|
|
331
|
+
* `../INDEX`-style hops), then by bare basename id as the fallback for
|
|
332
|
+
* hand-typed targets. Unresolvable entries are dropped, not rendered dead. */
|
|
333
|
+
const related = useMemo(() => {
|
|
334
|
+
const entries = doc?.metadata?.related
|
|
335
|
+
if (!Array.isArray(entries) || !doc?.file) return []
|
|
336
|
+
return entries
|
|
337
|
+
.map((entry) => {
|
|
338
|
+
const m = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/.exec(String(entry))
|
|
339
|
+
const target = (m ? m[1] : String(entry)).trim()
|
|
340
|
+
const display = (m?.[2] || target.split('/').pop().replace(/\.md$/, '')).trim()
|
|
341
|
+
const segs = doc.file.split('/').slice(0, -1)
|
|
342
|
+
for (const seg of target.replace(/\.md$/, '').split('/')) {
|
|
343
|
+
if (seg === '..') segs.pop()
|
|
344
|
+
else if (seg && seg !== '.') segs.push(seg)
|
|
345
|
+
}
|
|
346
|
+
const full = segs.join('/')
|
|
347
|
+
const basename = target.split('/').pop().replace(/\.md$/, '')
|
|
348
|
+
const hit =
|
|
349
|
+
inventory.find((d) => d.file.replace(/\.md$/, '') === full) ??
|
|
350
|
+
inventory.find((d) => d.file.replace(/\.md$/, '').toLowerCase() === `${full}/index`.toLowerCase()) ??
|
|
351
|
+
inventory.find((d) => d.id === basename)
|
|
352
|
+
return hit ? { id: hit.id, href: docHref(hit.id), label: display } : null
|
|
353
|
+
})
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
}, [doc, inventory, docHref])
|
|
356
|
+
|
|
298
357
|
/* The reader is usable OUTSIDE a ShellLayout (a plain page, a lobby route):
|
|
299
358
|
* ShellTocContext then defaults to null, so calling it unguarded threw.
|
|
300
359
|
* No shell → no right rail, and the reader just renders its article. */
|
|
@@ -305,6 +364,7 @@ const DocumentationReader = ({
|
|
|
305
364
|
key={docId}
|
|
306
365
|
toc={toc}
|
|
307
366
|
allTags={allTags}
|
|
367
|
+
related={related}
|
|
308
368
|
docId={docId}
|
|
309
369
|
docsIndexHref={docsIndex}
|
|
310
370
|
componentsHref={components}
|
|
@@ -312,7 +372,7 @@ const DocumentationReader = ({
|
|
|
312
372
|
/>
|
|
313
373
|
)
|
|
314
374
|
return () => setTocContent(null)
|
|
315
|
-
}, [setTocContent, docId, toc, allTags, docsIndex, components, docFilePath])
|
|
375
|
+
}, [setTocContent, docId, toc, allTags, related, docsIndex, components, docFilePath])
|
|
316
376
|
|
|
317
377
|
if (!doc) {
|
|
318
378
|
return (
|
|
@@ -14,12 +14,17 @@ import { Link } from 'react-router-dom'
|
|
|
14
14
|
* @param {Function} tagHref (tag) => href for `#hashtag` pills. Default
|
|
15
15
|
* keeps hashtags route-agnostic (`/docs?tag=…`);
|
|
16
16
|
* the reader passes a configured href in.
|
|
17
|
+
* @param {Function} onTagClick (tag) => void — when provided, hashtag pills
|
|
18
|
+
* render as buttons (tag mode) instead of Links;
|
|
19
|
+
* a consumer without a tag route stops shipping
|
|
20
|
+
* dead `/docs?tag=…` links (wave-4 parity).
|
|
17
21
|
*/
|
|
18
22
|
export const renderInlineTokens = (
|
|
19
23
|
tokens,
|
|
20
24
|
key = '',
|
|
21
25
|
resolveDocLink = null,
|
|
22
|
-
tagHref = (tag) => `/docs?tag=${encodeURIComponent(tag)}
|
|
26
|
+
tagHref = (tag) => `/docs?tag=${encodeURIComponent(tag)}`,
|
|
27
|
+
onTagClick = null
|
|
23
28
|
) => {
|
|
24
29
|
if (!Array.isArray(tokens)) return null
|
|
25
30
|
|
|
@@ -82,16 +87,26 @@ export const renderInlineTokens = (
|
|
|
82
87
|
</code>
|
|
83
88
|
)
|
|
84
89
|
|
|
85
|
-
case 'hashtag':
|
|
90
|
+
case 'hashtag': {
|
|
91
|
+
const pillCls = 'inline-tag-pill kol-helper-12 bg-fg-08 text-strong'
|
|
92
|
+
if (onTagClick) {
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
key={tokenKey}
|
|
96
|
+
type="button"
|
|
97
|
+
className={pillCls}
|
|
98
|
+
onClick={() => onTagClick(token.tag)}
|
|
99
|
+
>
|
|
100
|
+
#{token.tag}
|
|
101
|
+
</button>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
86
104
|
return (
|
|
87
|
-
<Link
|
|
88
|
-
key={tokenKey}
|
|
89
|
-
to={tagHref(token.tag)}
|
|
90
|
-
className="inline-tag-pill kol-helper-12 bg-fg-08 text-strong"
|
|
91
|
-
>
|
|
105
|
+
<Link key={tokenKey} to={tagHref(token.tag)} className={pillCls}>
|
|
92
106
|
#{token.tag}
|
|
93
107
|
</Link>
|
|
94
108
|
)
|
|
109
|
+
}
|
|
95
110
|
|
|
96
111
|
default:
|
|
97
112
|
return null
|
|
@@ -20,6 +20,19 @@ export function buildInventory(modules) {
|
|
|
20
20
|
const lines = raw.split(/\r?\n/)
|
|
21
21
|
const headingLine = lines.find((line) => line.startsWith('# ')) ?? ''
|
|
22
22
|
const title = headingLine.replace(/^#\s+/, '').trim()
|
|
23
|
+
/* Section headings (## / ###) ride the inventory so search can match a
|
|
24
|
+
* doc by its CONTENT, not just title/tags — the workshop reference
|
|
25
|
+
* behavior (matchSearchItems already takes `headings`; consumers shipped
|
|
26
|
+
* it empty because nothing extracted them — wave-4 parity, 2026-07-30). */
|
|
27
|
+
const headings = []
|
|
28
|
+
let inFence = false
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue }
|
|
31
|
+
if (!inFence && /^#{2,3}\s/.test(line)) {
|
|
32
|
+
const text = line.replace(/^#{2,3}\s+/, '').trim()
|
|
33
|
+
if (text) headings.push(text)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
const metadata = parseFrontmatter(raw)
|
|
24
37
|
const filename = normalisedPath.split('/').pop() ?? ''
|
|
25
38
|
const parentFolder = normalisedPath.split('/').slice(-2, -1)[0] ?? ''
|
|
@@ -33,7 +46,7 @@ export function buildInventory(modules) {
|
|
|
33
46
|
.replace(/^Design System\s*[-–—:]\s*/i, '')
|
|
34
47
|
.replace(/\bCheat Sheet\b/i, 'Quicklook')
|
|
35
48
|
|
|
36
|
-
return { id, file: normalisedPath, title: cleanTitle, metadata, parentFolder }
|
|
49
|
+
return { id, file: normalisedPath, title: cleanTitle, metadata, headings, parentFolder }
|
|
37
50
|
})
|
|
38
51
|
|
|
39
52
|
/* Collision safety (2026-07-30): filename-only ids collide across folders in
|
|
@@ -316,10 +316,14 @@ export const parseDocsMarkdown = (markdown) => {
|
|
|
316
316
|
const blocks = getTargetBlocks()
|
|
317
317
|
const lastBlock = blocks[blocks.length - 1]
|
|
318
318
|
|
|
319
|
+
/* Split on UNESCAPED pipes only, then unescape: Obsidian tables carry
|
|
320
|
+
* `[[target\|display]]` wikilinks, and a naive split('|') exploded the
|
|
321
|
+
* link across two cells and left literal `\` in the text — the reason
|
|
322
|
+
* hub INDEX pages (tables of wikilinks) rendered as junk (wave-4). */
|
|
319
323
|
const cells = trimmed
|
|
320
324
|
.slice(1, -1)
|
|
321
|
-
.split(
|
|
322
|
-
.map((cell) => cell.trim())
|
|
325
|
+
.split(/(?<!\\)\|/)
|
|
326
|
+
.map((cell) => cell.trim().replace(/\\\|/g, '|'))
|
|
323
327
|
|
|
324
328
|
// Separator row (|---|---|)
|
|
325
329
|
const isSeparator = cells.every((cell) => /^:?-+:?$/.test(cell))
|