@kolkrabbi/kol-workshop 0.3.4 → 0.3.5

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.5",
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-framework": "0.8.1",
20
+ "@kolkrabbi/kol-theme": "0.13.3"
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(
@@ -163,9 +182,11 @@ const DocumentationReader = ({
163
182
  return null
164
183
  }
165
184
 
166
- // Bind the resolved link + tag helpers so call sites stay terse.
185
+ // Bind the resolved link + tag helpers so call sites stay terse. Hashtag
186
+ // pills open TAG MODE (wave-4 parity) — the tagHref Link was a dead route
187
+ // in any consumer without a ?tag= index page.
167
188
  const renderTokens = (tokens, tokenKey) =>
168
- renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref)
189
+ renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref, openTagMode)
169
190
 
170
191
  /* ONE block renderer for intro + section content (wave-4 retype, 2026-07-30
171
192
  * — the two switches had already drifted). Every element is typed through
@@ -295,6 +316,35 @@ const DocumentationReader = ({
295
316
  return h1Block?.content || null
296
317
  }, [introBlocks])
297
318
 
319
+ /* Cross-references (wave-4 parity): frontmatter `related:` entries are
320
+ * wikilinks — `[[target|display]]`, target relative to the doc's own
321
+ * folder. Resolved against the inventory by full path first (handles
322
+ * `../INDEX`-style hops), then by bare basename id as the fallback for
323
+ * hand-typed targets. Unresolvable entries are dropped, not rendered dead. */
324
+ const related = useMemo(() => {
325
+ const entries = doc?.metadata?.related
326
+ if (!Array.isArray(entries) || !doc?.file) return []
327
+ return entries
328
+ .map((entry) => {
329
+ const m = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/.exec(String(entry))
330
+ const target = (m ? m[1] : String(entry)).trim()
331
+ const display = (m?.[2] || target.split('/').pop().replace(/\.md$/, '')).trim()
332
+ const segs = doc.file.split('/').slice(0, -1)
333
+ for (const seg of target.replace(/\.md$/, '').split('/')) {
334
+ if (seg === '..') segs.pop()
335
+ else if (seg && seg !== '.') segs.push(seg)
336
+ }
337
+ const full = segs.join('/')
338
+ const basename = target.split('/').pop().replace(/\.md$/, '')
339
+ const hit =
340
+ inventory.find((d) => d.file.replace(/\.md$/, '') === full) ??
341
+ inventory.find((d) => d.file.replace(/\.md$/, '').toLowerCase() === `${full}/index`.toLowerCase()) ??
342
+ inventory.find((d) => d.id === basename)
343
+ return hit ? { id: hit.id, href: docHref(hit.id), label: display } : null
344
+ })
345
+ .filter(Boolean)
346
+ }, [doc, inventory, docHref])
347
+
298
348
  /* The reader is usable OUTSIDE a ShellLayout (a plain page, a lobby route):
299
349
  * ShellTocContext then defaults to null, so calling it unguarded threw.
300
350
  * No shell → no right rail, and the reader just renders its article. */
@@ -305,6 +355,7 @@ const DocumentationReader = ({
305
355
  key={docId}
306
356
  toc={toc}
307
357
  allTags={allTags}
358
+ related={related}
308
359
  docId={docId}
309
360
  docsIndexHref={docsIndex}
310
361
  componentsHref={components}
@@ -312,7 +363,7 @@ const DocumentationReader = ({
312
363
  />
313
364
  )
314
365
  return () => setTocContent(null)
315
- }, [setTocContent, docId, toc, allTags, docsIndex, components, docFilePath])
366
+ }, [setTocContent, docId, toc, allTags, related, docsIndex, components, docFilePath])
316
367
 
317
368
  if (!doc) {
318
369
  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