@kolkrabbi/kol-workshop 0.3.3 → 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.3",
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",
@@ -15,9 +15,9 @@
15
15
  "d3": "^7.9.0",
16
16
  "@kolkrabbi/kol-brand": "^0.1.2",
17
17
  "@kolkrabbi/kol-component": "0.14.1",
18
- "@kolkrabbi/kol-icons": "0.8.10",
19
- "@kolkrabbi/kol-framework": "0.7.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",
@@ -1,7 +1,13 @@
1
1
  const join = (...classes) => classes.filter(Boolean).join(' ')
2
2
 
3
+ /* NOT .kol-prose (wave-4 retype, 2026-07-30 — user law: one layout = one style
4
+ * system; prose is the blog/CMS voice, never docs surfaces). Every element the
5
+ * reader renders is typed explicitly through the kol-doc-* roles, mirroring
6
+ * showcase/src/lib/mdx-components.jsx — the SAME dialect MDX pages speak.
7
+ * Vertical rhythm comes from the flex gap (the MdxDoc content idiom), not
8
+ * prose margins. */
3
9
  const DocsArticle = ({ children, className }) => (
4
- <article className={join('docs-article kol-prose', className)}>{children}</article>
10
+ <article className={join('docs-article flex w-full min-w-0 flex-col gap-6', className)}>{children}</article>
5
11
  )
6
12
 
7
13
  export default DocsArticle
@@ -1,9 +1,13 @@
1
1
  const join = (...classes) => classes.filter(Boolean).join(' ')
2
2
 
3
+ /* Doc voice (wave-4 retype, 2026-07-30): title/subtitle ride the DocHeader
4
+ * contract roles (kol-doc-heading / kol-doc-lede) instead of the old
5
+ * kol-sans-heading-02 + kol-mono-14 pair — one header voice across the
6
+ * reader, MDX pages and the Doc* kit. */
3
7
  const DocsHeader = ({ title, subtitle, meta = [], className }) => (
4
- <header className={join('docs-header', className)}>
5
- {title ? <h1 className="kol-sans-heading-02 text-strong">{title}</h1> : null}
6
- {subtitle ? <p className="kol-mono-14 text-meta">{subtitle}</p> : null}
8
+ <header className={join('docs-header flex flex-col gap-3', className)}>
9
+ {title ? <h1 className="kol-doc-heading">{title}</h1> : null}
10
+ {subtitle ? <p className="kol-doc-lede">{subtitle}</p> : null}
7
11
  {Array.isArray(meta) && meta.length > 0 ? (
8
12
  <div className="docs-meta kol-helper-10 text-body">
9
13
  {meta.map(({ label, value }) => (
@@ -1,12 +1,13 @@
1
1
  import { useContext, useEffect, useMemo, useState } from 'react'
2
2
  import { useParams, Link, useNavigate } from 'react-router-dom'
3
- import { CodeBlock, Divider, DocsToc, Icon, Tag } from '@kolkrabbi/kol-component'
3
+ import { CodeBlock, Divider, DocsToc, Icon, Table, Tag } from '@kolkrabbi/kol-component'
4
4
  import { ShellTocContext } from '../shell'
5
5
  import { useTagMode } from '../tags'
6
6
  import { parseDocsMarkdown, isIndexFile, getTagColor } from '../engine'
7
7
  import DocsHeader from './DocsHeader.jsx'
8
8
  import DocsArticle from './DocsArticle.jsx'
9
9
  import DocsFrontmatter from './DocsFrontmatter.jsx'
10
+ import { DocSection } from './DocKit.jsx'
10
11
  import { renderInlineTokens } from './render-tokens.jsx'
11
12
 
12
13
  const SidebarSection = ({ sectionKey, label, collapsedSections, toggleSection, children }) => (
@@ -22,7 +23,7 @@ const SidebarSection = ({ sectionKey, label, collapsedSections, toggleSection, c
22
23
  </div>
23
24
  )
24
25
 
25
- const DocReaderSidebar = ({ toc, allTags, docId, docsIndexHref, componentsHref, docFilePath }) => {
26
+ const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, componentsHref, docFilePath }) => {
26
27
  const navigate = useNavigate()
27
28
  const { openTagMode } = useTagMode()
28
29
  const [collapsedSections, setCollapsedSections] = useState({})
@@ -39,6 +40,24 @@ const DocReaderSidebar = ({ toc, allTags, docId, docsIndexHref, componentsHref,
39
40
  <DocsToc toc={toc} />
40
41
  </SidebarSection>
41
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
+
42
61
  <SidebarSection
43
62
  sectionKey="actions"
44
63
  label="Quick actions"
@@ -139,6 +158,7 @@ const DocumentationReader = ({
139
158
 
140
159
  const { docId } = useParams()
141
160
  const setTocContent = useContext(ShellTocContext)
161
+ const { openTagMode } = useTagMode()
142
162
 
143
163
  // Build a Set of known doc IDs for fast lookup
144
164
  const knownDocIds = useMemo(
@@ -162,9 +182,87 @@ const DocumentationReader = ({
162
182
  return null
163
183
  }
164
184
 
165
- // 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.
166
188
  const renderTokens = (tokens, tokenKey) =>
167
- renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref)
189
+ renderInlineTokens(tokens, tokenKey, resolveDocLink, tagHref, openTagMode)
190
+
191
+ /* ONE block renderer for intro + section content (wave-4 retype, 2026-07-30
192
+ * — the two switches had already drifted). Every element is typed through
193
+ * the kol-doc-* dialect of showcase mdx-components.jsx: doc-body running
194
+ * text (self-caps at the measure), heading-05 h3/h4 (h4 = weight 400, the
195
+ * prose-h4 precedent), panel-capped furniture (width law: tables/code cap
196
+ * at --kol-content-panel), markdown tables through THE kol-component Table
197
+ * as a dynamic-column DocTable sibling (no parallel table markup). */
198
+ const renderBlock = (block, blockKey) => {
199
+ switch (block.type) {
200
+ case 'paragraph':
201
+ return (
202
+ <p key={blockKey} className="kol-doc-body">
203
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
204
+ </p>
205
+ )
206
+ case 'heading3':
207
+ return (
208
+ <h3 key={blockKey} id={block.id} className="kol-sans-heading-05 text-emphasis scroll-mt-20 mt-2">
209
+ {block.content}
210
+ </h3>
211
+ )
212
+ case 'heading4':
213
+ return (
214
+ <h4 key={blockKey} id={block.id} className="kol-sans-heading-05 font-normal text-emphasis scroll-mt-20">
215
+ {block.content}
216
+ </h4>
217
+ )
218
+ case 'list': {
219
+ const ListComponent = block.ordered ? 'ol' : 'ul'
220
+ const listClass = `flex ${block.ordered ? 'list-decimal' : 'list-disc'} flex-col gap-2 pl-5 kol-doc-body`
221
+ return block.items ? (
222
+ <ListComponent key={blockKey} className={listClass}>
223
+ {block.items.map((item, itemIndex) => (
224
+ <li key={itemIndex}>
225
+ {item.tokens ? renderTokens(item.tokens, `${blockKey}-item-${itemIndex}`) : item.content || item}
226
+ </li>
227
+ ))}
228
+ </ListComponent>
229
+ ) : null
230
+ }
231
+ case 'blockquote':
232
+ return (
233
+ <blockquote key={blockKey} className="docs-callout kol-doc-body">
234
+ {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
235
+ </blockquote>
236
+ )
237
+ case 'divider':
238
+ return <Divider key={blockKey} className="docs-divider" opacity="12" />
239
+ case 'code':
240
+ return (
241
+ <div key={blockKey} className="max-w-[var(--kol-content-panel)]">
242
+ <CodeBlock code={block.lines.join('\n')} language={block.lang} />
243
+ </div>
244
+ )
245
+ case 'table':
246
+ return (
247
+ <div key={blockKey} className="max-w-[var(--kol-content-panel)]">
248
+ <Table
249
+ variant="simple"
250
+ columns={block.headers.map((header, i) => ({ accessor: `c${i}`, header }))}
251
+ rows={block.rows.map((row, rowIndex) => ({
252
+ id: rowIndex,
253
+ ...Object.fromEntries(row.map((cell, cellIndex) => [
254
+ `c${cellIndex}`,
255
+ cell.tokens ? renderTokens(cell.tokens, `${blockKey}-row-${rowIndex}-cell-${cellIndex}`) : cell.content,
256
+ ])),
257
+ }))}
258
+ className="kol-doc-table"
259
+ />
260
+ </div>
261
+ )
262
+ default:
263
+ return null
264
+ }
265
+ }
168
266
 
169
267
  const doc = useMemo(() => {
170
268
  return inventory.find((d) => d.id === docId)
@@ -218,6 +316,35 @@ const DocumentationReader = ({
218
316
  return h1Block?.content || null
219
317
  }, [introBlocks])
220
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
+
221
348
  /* The reader is usable OUTSIDE a ShellLayout (a plain page, a lobby route):
222
349
  * ShellTocContext then defaults to null, so calling it unguarded threw.
223
350
  * No shell → no right rail, and the reader just renders its article. */
@@ -228,6 +355,7 @@ const DocumentationReader = ({
228
355
  key={docId}
229
356
  toc={toc}
230
357
  allTags={allTags}
358
+ related={related}
231
359
  docId={docId}
232
360
  docsIndexHref={docsIndex}
233
361
  componentsHref={components}
@@ -235,7 +363,7 @@ const DocumentationReader = ({
235
363
  />
236
364
  )
237
365
  return () => setTocContent(null)
238
- }, [setTocContent, docId, toc, allTags, docsIndex, components, docFilePath])
366
+ }, [setTocContent, docId, toc, allTags, related, docsIndex, components, docFilePath])
239
367
 
240
368
  if (!doc) {
241
369
  return (
@@ -254,172 +382,19 @@ const DocumentationReader = ({
254
382
  <DocsArticle>
255
383
  <DocsFrontmatter metadata={doc.metadata} docId={docId} />
256
384
  {docTitle && (
257
- <h1>{docTitle}</h1>
385
+ <h1 className="kol-doc-heading">{docTitle}</h1>
258
386
  )}
259
387
  {/* Render intro blocks (excluding H1 which is docTitle) */}
260
- {introBlocks.filter(b => b.type !== 'heading1').map((block, index) => {
261
- const blockKey = `intro-${block.type}-${index}`
262
- switch (block.type) {
263
- case 'paragraph':
264
- return (
265
- <p key={blockKey}>
266
- {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
267
- </p>
268
- )
269
- case 'heading3':
270
- return (
271
- <h3 key={blockKey} id={block.id}>
272
- {block.content}
273
- </h3>
274
- )
275
- case 'heading4':
276
- return (
277
- <h4 key={blockKey} id={block.id}>
278
- {block.content}
279
- </h4>
280
- )
281
- case 'list':
282
- return block.items ? (
283
- <ul key={blockKey} className="docs-list tight">
284
- {block.items.map((item, i) => (
285
- <li key={i}>
286
- {item.tokens ? renderTokens(item.tokens, `${blockKey}-item-${i}`) : item.content}
287
- </li>
288
- ))}
289
- </ul>
290
- ) : null
291
- case 'blockquote':
292
- return (
293
- <blockquote key={blockKey} className="docs-callout">
294
- {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
295
- </blockquote>
296
- )
297
- case 'divider':
298
- return <Divider key={blockKey} className="docs-divider" opacity="12" />
299
- case 'code':
300
- return (
301
- <CodeBlock
302
- key={blockKey}
303
- code={block.lines.join('\n')}
304
- language={block.lang}
305
- />
306
- )
307
- case 'table':
308
- return (
309
- <div key={blockKey} className="kol-table-wrapper kol-table--simple">
310
- <table className="kol-table">
311
- <thead className="kol-table-thead">
312
- <tr>
313
- {block.headers.map((header, headerIndex) => (
314
- <th key={headerIndex} className="kol-table-cell-title">{header}</th>
315
- ))}
316
- </tr>
317
- </thead>
318
- <tbody>
319
- {block.rows.map((row, rowIndex) => (
320
- <tr key={rowIndex} className="kol-table-row">
321
- {row.map((cell, cellIndex) => (
322
- <td key={cellIndex} className="kol-table-cell-text">
323
- {cell.tokens ? renderTokens(cell.tokens, `${blockKey}-row-${rowIndex}-cell-${cellIndex}`) : cell.content}
324
- </td>
325
- ))}
326
- </tr>
327
- ))}
328
- </tbody>
329
- </table>
330
- </div>
331
- )
332
- default:
333
- return null
334
- }
335
- })}
388
+ {introBlocks.filter(b => b.type !== 'heading1').map((block, index) =>
389
+ renderBlock(block, `intro-${block.type}-${index}`)
390
+ )}
336
391
 
392
+ {/* Sections through the packaged DocSection — the same anchored
393
+ * rule + h2 contract MDX pages compose with. */}
337
394
  {sections.map(({ heading, id, blocks }) => (
338
- <section key={id} id={id} className="space-y-6 scroll-mt-32">
339
- <h2>{heading}</h2>
340
- {blocks.map((block, index) => {
341
- const blockKey = `${id}-${block.type}-${index}`
342
-
343
- switch (block.type) {
344
- case 'heading3':
345
- return (
346
- <h3 key={blockKey} id={block.id}>
347
- {block.content}
348
- </h3>
349
- )
350
- case 'heading4':
351
- return (
352
- <h4 key={blockKey} id={block.id}>
353
- {block.content}
354
- </h4>
355
- )
356
- case 'paragraph':
357
- return (
358
- <p key={blockKey}>
359
- {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
360
- </p>
361
- )
362
- case 'list': {
363
- const listClass = block.ordered
364
- ? 'docs-list docs-list--ordered tight'
365
- : 'docs-list tight'
366
- const ListComponent = block.ordered ? 'ol' : 'ul'
367
- return (
368
- <ListComponent key={blockKey} className={listClass}>
369
- {block.items.map((item, itemIndex) => (
370
- <li key={itemIndex}>
371
- {item.tokens ? renderTokens(item.tokens, `${blockKey}-item-${itemIndex}`) : item.content || item}
372
- </li>
373
- ))}
374
- </ListComponent>
375
- )
376
- }
377
- case 'code':
378
- return (
379
- <CodeBlock
380
- key={blockKey}
381
- code={block.lines.join('\n')}
382
- language={block.lang}
383
- />
384
- )
385
- case 'blockquote':
386
- return (
387
- <blockquote key={blockKey} className="docs-callout">
388
- {block.tokens ? renderTokens(block.tokens, blockKey) : block.content}
389
- </blockquote>
390
- )
391
- case 'divider':
392
- return <Divider key={blockKey} className="docs-divider" opacity="12" />
393
- case 'table':
394
- return (
395
- <div key={blockKey} className="kol-table-wrapper kol-table--simple">
396
- <table className="kol-table">
397
- <thead className="kol-table-thead">
398
- <tr>
399
- {block.headers.map((header, headerIndex) => (
400
- <th key={headerIndex} className="kol-table-cell-title">{header}</th>
401
- ))}
402
- </tr>
403
- </thead>
404
- <tbody>
405
- {block.rows.map((row, rowIndex) => (
406
- <tr key={rowIndex} className="kol-table-row">
407
- {row.map((cell, cellIndex) => (
408
- <td key={cellIndex} className="kol-table-cell-text">
409
- {cell.tokens ? renderTokens(cell.tokens, `${blockKey}-row-${rowIndex}-cell-${cellIndex}`) : cell.content}
410
- </td>
411
- ))}
412
- </tr>
413
- ))}
414
- </tbody>
415
- </table>
416
- </div>
417
- )
418
- default:
419
- return null
420
- }
421
- })}
422
- </section>
395
+ <DocSection key={id} id={id} title={heading}>
396
+ {blocks.map((block, index) => renderBlock(block, `${id}-${block.type}-${index}`))}
397
+ </DocSection>
423
398
  ))}
424
399
  </DocsArticle>
425
400
  )
@@ -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
 
@@ -37,15 +42,19 @@ export const renderInlineTokens = (
37
42
  return <em key={tokenKey}>{token.content}</em>
38
43
 
39
44
  case 'code':
40
- return <code key={tokenKey}>{token.content}</code>
45
+ return <code key={tokenKey} className="kol-doc-code-inline">{token.content}</code>
41
46
 
42
47
  case 'link': {
48
+ /* One link idiom across doc surfaces (wave-4 retype): the anchor
49
+ * treatment from showcase mdx-components.jsx — colorless per the
50
+ * link law, emphasis + quiet underline. */
51
+ const linkCls = 'text-emphasis underline decoration-fg-16 underline-offset-2 hover:decoration-current'
43
52
  // For .md links, try to resolve to an app route
44
53
  if (resolveDocLink && token.url.includes('.md')) {
45
54
  const route = resolveDocLink(token.url)
46
55
  if (route) {
47
56
  return (
48
- <Link key={tokenKey} to={route} className="docs-link">
57
+ <Link key={tokenKey} to={route} className={linkCls}>
49
58
  {token.text}
50
59
  </Link>
51
60
  )
@@ -54,7 +63,7 @@ export const renderInlineTokens = (
54
63
  return token.text
55
64
  }
56
65
  return (
57
- <a key={tokenKey} href={token.url} className="docs-link">
66
+ <a key={tokenKey} href={token.url} className={linkCls}>
58
67
  {token.text}
59
68
  </a>
60
69
  )
@@ -78,16 +87,26 @@ export const renderInlineTokens = (
78
87
  </code>
79
88
  )
80
89
 
81
- 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
+ }
82
104
  return (
83
- <Link
84
- key={tokenKey}
85
- to={tagHref(token.tag)}
86
- className="inline-tag-pill kol-helper-12 bg-fg-08 text-strong"
87
- >
105
+ <Link key={tokenKey} to={tagHref(token.tag)} className={pillCls}>
88
106
  #{token.tag}
89
107
  </Link>
90
108
  )
109
+ }
91
110
 
92
111
  default:
93
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