@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.4",
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-framework": "0.8.0",
20
- "@kolkrabbi/kol-theme": "0.13.2"
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 basename = pathPart.split('/').pop().replace(/\.md$/, '')
159
- if (knownDocIds.has(basename)) {
160
- const route = docHref(basename)
161
- return anchor ? `${route}#${anchor}` : route
162
- }
163
- return null
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))