@kolkrabbi/kol-framework 0.57.0 → 0.59.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Site shell and page layout",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,14 +16,16 @@
16
16
  "peerDependencies": {
17
17
  "@kolkrabbi/kol-component": ">=0.298.0",
18
18
  "@kolkrabbi/kol-icons": ">=0.18.0",
19
+ "@kolkrabbi/kol-markdown": ">=0.2.0",
19
20
  "@kolkrabbi/kol-theme": ">=0.186.0",
20
21
  "react": "^18.3.0 || ^19.0.0",
21
22
  "react-dom": "^18.3.0 || ^19.0.0",
22
23
  "react-router-dom": "^6.0.0 || ^7.0.0"
23
24
  },
24
25
  "devDependencies": {
25
- "@kolkrabbi/kol-component": "^0.303.0",
26
- "@kolkrabbi/kol-icons": "^0.46.0"
26
+ "@kolkrabbi/kol-markdown": "0.2.0",
27
+ "@kolkrabbi/kol-component": "^0.307.0",
28
+ "@kolkrabbi/kol-icons": "^0.49.0"
27
29
  },
28
30
  "files": [
29
31
  "src",
@@ -0,0 +1,90 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /* LINK PREVIEW — kol-atlas's hover card (apps/workspace/src/components/LinkPreview.jsx + previews.js), carried for the
4
+ * document space: hover a link in the sheet and, after a short pause, a card opens under it — an image on top, a short
5
+ * text below, Wikipedia-style. `#page` previews the page's title, intro and thumbnail; `#page--section` the section's
6
+ * heading, first paragraph and first image (its page's thumbnail when it has none). Moving into the card keeps it open;
7
+ * leaving both closes it; the card follows the link. Touch: a long press opens the card, a tap follows the link.
8
+ * Not inside the note list — a link's note is its constant source, the card already showed in the text.
9
+ *
10
+ * cardFor(a) (anchor element) => { title, text, image, graphic, site, href } | null — the app's, from its corpus */
11
+ const W = 320
12
+ const PAUSE = 400
13
+ const PRESS = 400
14
+ const isGraphic = (file = '') => /\.(svg|png)$/i.test(file)
15
+
16
+ export default function LinkPreview({ root, cardFor }) {
17
+ const [card, setCard] = useState(null)
18
+ const timer = useRef(null)
19
+ const over = useRef(false)
20
+
21
+ useEffect(() => {
22
+ const el = root?.current ?? document
23
+ const clear = () => { clearTimeout(timer.current); timer.current = null }
24
+ const close = () => { clear(); setCard(null) }
25
+ const linkOf = (t) => t.closest?.('.kol-sheet a[href]')
26
+ const inNotes = (a) => a.closest('ol.fns')
27
+ const open = (a) => {
28
+ const data = cardFor(a)
29
+ if (!data) return
30
+ const r = a.getBoundingClientRect()
31
+ /* under the link; above it near the bottom of the window; kept inside the viewport's width */
32
+ const below = window.innerHeight - r.bottom > 260
33
+ setCard({ ...data, left: Math.min(Math.max(8, r.left), window.innerWidth - W - 8), top: below ? r.bottom + 8 : null, bottom: below ? null : window.innerHeight - r.top + 8 })
34
+ }
35
+ const onOver = (e) => {
36
+ const a = linkOf(e.target)
37
+ if (!a || inNotes(a) || e.pointerType === 'touch') return
38
+ clear()
39
+ timer.current = setTimeout(() => open(a), PAUSE)
40
+ }
41
+ const onOut = (e) => {
42
+ const a = linkOf(e.target)
43
+ if (!a) return
44
+ clear()
45
+ setTimeout(() => { if (!over.current) setCard(null) }, 120)
46
+ }
47
+ let press = null
48
+ const onDown = (e) => { const a = linkOf(e.target); if (!a || e.pointerType !== 'touch' || inNotes(a)) return; press = setTimeout(() => { open(a); a.dataset.held = '1' }, PRESS) }
49
+ const onUp = () => { clearTimeout(press); press = null }
50
+ const onClick = (e) => { const a = linkOf(e.target); if (a?.dataset.held) { delete a.dataset.held; e.preventDefault() } else if (!e.target.closest('[data-link-card]')) setCard(null) }
51
+ const onKey = (e) => { if (e.key === 'Escape') close() }
52
+ el.addEventListener('pointerover', onOver)
53
+ el.addEventListener('pointerout', onOut)
54
+ el.addEventListener('pointerdown', onDown)
55
+ el.addEventListener('pointerup', onUp)
56
+ document.addEventListener('click', onClick, true)
57
+ window.addEventListener('keydown', onKey)
58
+ window.addEventListener('scroll', close, true)
59
+ return () => {
60
+ el.removeEventListener('pointerover', onOver); el.removeEventListener('pointerout', onOut)
61
+ el.removeEventListener('pointerdown', onDown); el.removeEventListener('pointerup', onUp)
62
+ document.removeEventListener('click', onClick, true); window.removeEventListener('keydown', onKey); window.removeEventListener('scroll', close, true)
63
+ clear()
64
+ }
65
+ }, [root, cardFor])
66
+
67
+ if (!card) return null
68
+ return (
69
+ <a
70
+ href={card.href}
71
+ data-link-card
72
+ onPointerEnter={() => { over.current = true }}
73
+ onPointerLeave={() => { over.current = false; setCard(null) }}
74
+ className="fixed z-[var(--kol-z-tooltip,60)] block overflow-hidden rounded border border-fg-12 bg-surface-primary no-underline shadow-[0_8px_24px_rgba(0,0,0,.18)]"
75
+ style={{ width: W, left: card.left, top: card.top ?? undefined, bottom: card.bottom ?? undefined }}
76
+ >
77
+ {card.image && (
78
+ /* the image keeps its own aspect between 1:1 and 2:1; a graphic is shown whole in a 16:9 frame on the soft ground */
79
+ <div className={`w-full overflow-hidden ${card.graphic ? 'aspect-video bg-fg-04' : 'max-h-80'}`}>
80
+ <img src={card.image} alt="" className={`block w-full ${card.graphic ? 'h-full object-contain p-[6%]' : 'h-auto min-h-40 max-h-80 object-cover'}`} />
81
+ </div>
82
+ )}
83
+ <div className="flex flex-col gap-1 p-3">
84
+ <span className="kol-mono-12 text-emphasis">{card.title}</span>
85
+ {card.text && <span className="kol-mono-12 text-body line-clamp-3">{card.text}</span>}
86
+ {card.site && <span className="kol-helper-10 text-meta uppercase tracking-wider mt-1">{card.site}</span>}
87
+ </div>
88
+ </a>
89
+ )
90
+ }
@@ -0,0 +1,264 @@
1
+ import { useContext, useLayoutEffect, useMemo } from 'react'
2
+ import { CodeBlock, DocsToc, Table } from '@kolkrabbi/kol-component'
3
+ import { ShellTocContext } from './SiteLayout.jsx'
4
+ import { parseDocsMarkdown, splitFrontmatter } from '@kolkrabbi/kol-markdown'
5
+
6
+ /* MARKDOWN PAGE — a markdown file as a document page (plan-2026-10-28-document W3.2, born in apps/document, home here
7
+ * since W5.2 after his review, 2026-10-28; kol-website's MarkdownPage ticket). kol-markdown's blocks written as the
8
+ * markup `.kol-sheet` already styles (kol-theme's port of kol-atlas's workspace.css, class for
9
+ * class): the head, `.proj` with the `num` gutter when the document is numbered, `p.when` meta lines with their `k`,
10
+ * `.mk` marks and `.why` reasons, `sup.fn` + `ol.fns` notes under the passage that cites them (kol-atlas's rule: a
11
+ * note sits at the end of its phase, not the page), `ol.fns.src` for Sources, tables on the DS `Table` inside
12
+ * `.tablewrap` (a key–value table plain), `CodeBlock` for fences, the `.media` figure grid, blockquote with `cite`,
13
+ * `.tb` item text. No class of its own.
14
+ *
15
+ * source the file's text, frontmatter included
16
+ * numbered a numbered document (kol-atlas's `proj`): `.proj`, the number gutter, `data-ms` levels, `.tb` items
17
+ * toc 'frame' (default): the headings go to the frame's TOC rail through ShellTocContext, nothing drawn here;
18
+ * without a frame, or with 'rail', the DS rail (DocsToc variant="rail"); false: none
19
+ * linkNotes every external link becomes a numbered note too (kol-atlas's `footnotes` tabs: Project, SEO + GEO)
20
+ * fit 'cover' (default): a figure fills its frame; 'contain': the whole image on the plate
21
+ * ratio the figure frame's aspect ratio ('4/3' by default, from the theme)
22
+ * resolveLink (url) => href — the app maps `#page`, `#page--section` and `.md` links to its routes
23
+ * assetUrl (file) => url for `![[file]]` embeds; null draws the file's name on a plate */
24
+ const jumpTo = (e) => {
25
+ e.preventDefault()
26
+ const id = e.currentTarget.getAttribute('href')?.slice(1)
27
+ if (id) document.getElementById(id)?.scrollIntoView({ block: 'start' })
28
+ }
29
+ const MARK = { hand: '✋', park: '🅿️', pin: '📍', done: '✓' }
30
+ const isGraphic = (file) => /\.(svg|png)$/i.test(file)
31
+ const isExternal = (url) => /^https?:\/\//.test(url)
32
+ const shortUrl = (url) => { const s = url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, ''); try { return decodeURIComponent(s) } catch { return s } }
33
+
34
+ /* THE NOTES — kol-atlas's engine rule (apps/workspace/src/engine/render.js, 2026-10-20): one sequence through the page in
35
+ * reading order, headings included. A `[^key]` takes its number at its first reference, or when its definition line is
36
+ * listed; with linkNotes every external link takes a new number where it stands (the same address twice in one passage
37
+ * shares one). The pending notes are listed before the next ## or ### and at the end, under the passage they belong to;
38
+ * a note's own text can carry links, which join that list. Every link and footnote token is given its number (`t.note`)
39
+ * so the text and the list agree. */
40
+ function collectNotes(parsed, linkNotes) {
41
+ const at = {} // passage id → [{ key, n, def }]
42
+ const defs = {} // key → { tokens } (a footnote's text) | { text, url } (a link's)
43
+ const num = {} // key → number
44
+ let n = 0, pend = [], passage = 'intro'
45
+ const scan = (tokens = []) => tokens.forEach((t) => {
46
+ if (t.type === 'footnote') { if (!(t.key in num)) num[t.key] = ++n; t.note = num[t.key] }
47
+ else if (linkNotes && t.type === 'link' && isExternal(t.url)) {
48
+ let k = pend.find((x) => defs[x]?.url === t.url)
49
+ if (!k) { k = `u${++n}`; num[k] = n; defs[k] = { text: t.text, url: t.url }; pend.push(k) }
50
+ t.note = num[k]
51
+ } else if (t.type === 'link') t.note = undefined
52
+ })
53
+ const scanBlock = (b) => {
54
+ scan(b.tokens); scan(b.citeTokens)
55
+ b.rows?.forEach((r) => (Array.isArray(r) ? r.forEach((c) => scan(c?.tokens)) : scan(r.tokens)))
56
+ b.header?.forEach((c) => scan(c?.tokens))
57
+ b.items?.forEach((it) => scan(it.tokens ?? it.captionTokens))
58
+ }
59
+ const flush = () => {
60
+ if (!pend.length) return
61
+ const list = []
62
+ while (pend.length) {
63
+ const ks = pend.sort((a, b) => (num[a] ?? 1e9) - (num[b] ?? 1e9)); pend = []
64
+ ks.forEach((k) => { if (!(k in num)) num[k] = ++n; list.push({ key: k, n: num[k], def: defs[k] ?? {} }); if (defs[k]?.tokens) scan(defs[k].tokens) })
65
+ }
66
+ at[passage] = list
67
+ }
68
+ const walk = (blocks, sectionId) => {
69
+ blocks.forEach((b) => {
70
+ if (b.type === 'heading3') { flush(); passage = `${sectionId}/${b.id}`; scan(b.tokens); return }
71
+ if (b.type === 'footnote-def') { defs[b.key] = { tokens: b.tokens }; pend.push(b.key); return }
72
+ scanBlock(b)
73
+ })
74
+ }
75
+ walk(parsed.introBlocks, 'intro')
76
+ parsed.sections.forEach((s) => { flush(); passage = s.id; scan(s.tokens); walk(s.blocks, s.id) })
77
+ flush()
78
+ return { at, defs }
79
+ }
80
+
81
+ /* inline tokens → elements */
82
+ function Inline({ tokens = [], ctx }) {
83
+ return tokens.map((t, i) => {
84
+ switch (t.type) {
85
+ case 'text': return t.content
86
+ case 'bold': return <strong key={i}>{t.tokens ? <Inline tokens={t.tokens} ctx={ctx} /> : t.content}</strong>
87
+ case 'italic': return <em key={i}>{t.tokens ? <Inline tokens={t.tokens} ctx={ctx} /> : t.content}</em>
88
+ case 'code': return <code key={i}>{t.content}</code>
89
+ case 'link': {
90
+ const ext = isExternal(t.url)
91
+ return <span key={i}><a href={ctx.resolveLink(t.url)} target={ext ? '_blank' : undefined} rel={ext ? 'noopener' : undefined}>{t.text}</a>{t.note && <sup className="fn"><a href={`#fn-${t.note}`}>{t.note}</a></sup>}</span>
92
+ }
93
+ case 'image': {
94
+ /* a markdown image names a file beside the document (`_assets/x.png`, `./x.png`) — the fixture's url for it; a web image as written */
95
+ const local = !/^(https?:|data:|\/)/.test(t.src)
96
+ return <img key={i} src={(local && ctx.assetUrl(t.src.split('/').pop())) || t.src} alt={t.alt} loading="lazy" />
97
+ }
98
+ case 'colorswatch': return <span key={i} className="inline-block size-3 align-middle rounded-sm border border-fg-12" style={{ background: t.color }} />
99
+ case 'hashtag': return `#${t.tag}`
100
+ case 'footnote': return <sup key={i} className="fn"><a href={`#fn-${t.note}`}>{t.note}</a></sup>
101
+ default: return null
102
+ }
103
+ })
104
+ }
105
+
106
+ /* a mark with a reason follows the title (`.mk` + `.why`); a bare mark (📍 Sync) leads it, as kol-atlas prints it */
107
+ const Marks = ({ marks = [], lead }) => marks.filter((m) => m.kind !== 'done' && Boolean(m.reason) !== Boolean(lead)).map((m, i) => (
108
+ lead ? <span key={i}>{MARK[m.kind]} </span> : <span key={i}><span className="mk">{MARK[m.kind]}</span>{m.reason && <span className="why">{m.reason}</span>}</span>
109
+ ))
110
+
111
+ function Heading({ as: Tag, block, ctx }) {
112
+ const done = block.marks?.some((m) => m.kind === 'done')
113
+ return (
114
+ <Tag id={`a-${block.id}`} className={done ? 'done' : undefined}>
115
+ {block.num && <span className="num">{block.num}</span>}
116
+ <Marks marks={block.marks} lead />
117
+ <Inline tokens={block.tokens ?? [{ type: 'text', content: block.content }]} ctx={ctx} />
118
+ <Marks marks={block.marks} />
119
+ </Tag>
120
+ )
121
+ }
122
+
123
+ /* the notes of one passage: a footnote's text, or a link's text and short address */
124
+ function Notes({ list = [], ms, ctx }) {
125
+ if (!list.length) return null
126
+ return (
127
+ <ol className="fns" data-ms={ms ? '' : undefined} start={list[0].n}>
128
+ {list.map(({ key, n, def: d }) => (
129
+ <li key={key} id={`fn-${n}`}>
130
+ <span className="fnn">{n}.</span>
131
+ {d.url ? <><a href={d.url} target="_blank" rel="noopener">{d.text}</a> <span className="url">{shortUrl(d.url)}</span></> : <Inline tokens={d.tokens ?? []} ctx={ctx} />}
132
+ </li>
133
+ ))}
134
+ </ol>
135
+ )
136
+ }
137
+
138
+ /* the figure grid: a workspace image by its file, or a web link card (`![caption](https://…)`) — its stored screenshot,
139
+ * the figure a link to the site, the site's name on the plate when there is no shot. A figure fills its frame (`fit`
140
+ * 'cover', the theme's default); `fit="contain"` shows the whole image on the plate; `ratio` sets the frame ('4/3') */
141
+ function MediaGrid({ items, ctx }) {
142
+ const cols = items.length <= 2 ? 2 : items.length === 3 ? 3 : 4
143
+ return (
144
+ <div className="media" data-fit={ctx.fit === 'contain' ? 'contain' : undefined} style={{ '--cols': cols, ...(ctx.ratio ? { '--media-ratio': ctx.ratio } : {}) }}>
145
+ {items.map((it, i) => {
146
+ const web = isExternal(it.file)
147
+ const url = ctx.assetUrl(it.file)
148
+ const fig = (
149
+ <figure key={i} className={!web && isGraphic(it.file) ? 'g' : undefined}>
150
+ {url ? <img src={url} alt={it.caption || it.file} loading="lazy" /> : <div className="doc">{web ? shortUrl(it.file) : it.file}</div>}
151
+ <figcaption>{it.captionTokens ? <Inline tokens={it.captionTokens} ctx={ctx} /> : it.caption || it.file.split('/').pop()}</figcaption>
152
+ </figure>
153
+ )
154
+ return web ? <a key={i} href={it.file} className="no-underline">{fig}</a> : fig
155
+ })}
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function TableBlock({ block, ms, ctx }) {
161
+ const cell = (c, key) => <Inline key={key} tokens={c.tokens} ctx={ctx} />
162
+ if (block.kv) {
163
+ return (
164
+ <div className="tablewrap kv" data-ms={ms ? '' : undefined}>
165
+ <table><tbody>{block.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j} className={block.align?.[j] === 'right' ? 'num' : undefined}>{cell(c, j)}</td>)}</tr>)}</tbody></table>
166
+ </div>
167
+ )
168
+ }
169
+ const columns = block.headers.map((h, j) => ({ accessor: `c${j}`, header: h, wrap: true, align: block.align?.[j] }))
170
+ const rows = block.rows.map((r, i) => Object.fromEntries([['id', i], ...r.map((c, j) => [`c${j}`, cell(c, `${i}-${j}`)])]))
171
+ return <div className="tablewrap" data-ms={ms ? '' : undefined}><Table variant="simple" columns={columns} rows={rows} /></div>
172
+ }
173
+
174
+ /* a section's blocks in order; each passage's notes close it. In a numbered document (`.proj`) the text has three
175
+ * levels, kol-atlas's: MILESTONE text (the intro, and under a ## before any ###) hangs back into the gutter with
176
+ * `data-ms`; PHASE text (under a ###) sits at the padded edge; ITEM text (under a ####) is `.tb`, one gutter further. */
177
+ function Blocks({ blocks, sectionId, sources, ctx }) {
178
+ const out = []
179
+ let passage = sectionId
180
+ let level = 'ms' // 'ms' · 'phase' · 'item'
181
+ let skipList = false // the list under a pop-out definition is the pop-out's, not the page's
182
+ const ms = () => (ctx.proj && level === 'ms' ? '' : undefined)
183
+ const tb = () => (ctx.proj && level === 'item' ? 'tb' : undefined)
184
+ const close = (key) => out.push(<Notes key={`notes-${key}`} list={ctx.notes.at[passage]} ms={ms() !== undefined} ctx={ctx} />)
185
+ blocks.forEach((b, i) => {
186
+ switch (b.type) {
187
+ case 'heading3': close(i); passage = `${sectionId}/${b.id}`; level = 'phase'; out.push(<Heading key={i} as="h3" block={b} ctx={ctx} />); break
188
+ case 'heading4': level = 'item'; out.push(<Heading key={i} as="h4" block={b} ctx={ctx} />); break
189
+ case 'meta': {
190
+ /* one `p.when` per run of meta lines, the lines on <br>; an Acceptance line is its own `p.dw` (the engine's rule) */
191
+ const runs = []
192
+ b.rows.forEach((r) => { const dw = r.key === 'Acceptance'; const last = runs[runs.length - 1]; if (!dw && last && !last.dw) last.rows.push(r); else runs.push({ dw, rows: [r] }) })
193
+ runs.forEach((run, j) => out.push(
194
+ <p key={`${i}-${j}`} className={run.dw ? 'dw' : 'when'} data-ms={ms()}>
195
+ {run.rows.map((r, k) => <span key={k}>{k > 0 && <br />}<span className="k">{r.key}:</span> <Inline tokens={r.tokens} ctx={ctx} /></span>)}
196
+ </p>,
197
+ ))
198
+ break
199
+ }
200
+ case 'paragraph': out.push(<p key={i} className={b.tokens.length === 1 && b.tokens[0].type === 'italic' ? 'note' : tb()} data-ms={ms()}><Inline tokens={b.tokens} ctx={ctx} /></p>); break
201
+ case 'list': {
202
+ if (skipList) { skipList = false; break }
203
+ /* a Sources list is numbered notes whatever its bullet (kol-atlas's rule) */
204
+ const L = b.ordered || sources ? 'ol' : 'ul'
205
+ out.push(<L key={i} className={sources ? 'fns src' : tb()} data-ms={ms()}>{b.items.map((it, j) => {
206
+ /* a Sources item: number, text, and the short address of its first link (the engine's rule) */
207
+ const u = sources && it.tokens.find((t) => t.type === 'link' && isExternal(t.url))?.url
208
+ return <li key={j}>{sources && <span className="fnn">{j + 1}.</span>}<Inline tokens={it.tokens} ctx={ctx} />{u && <> <span className="url">{shortUrl(u)}</span></>}</li>
209
+ })}</L>)
210
+ break
211
+ }
212
+ case 'blockquote': out.push(<blockquote key={i} data-ms={ms()}><p><Inline tokens={b.tokens} ctx={ctx} /></p>{b.cite && <cite><Inline tokens={b.citeTokens} ctx={ctx} /></cite>}</blockquote>); break
213
+ case 'code': out.push(<CodeBlock key={i} language={b.lang || 'text'} code={b.lines.join('\n')} />); break
214
+ case 'divider': out.push(<hr key={i} />); break
215
+ case 'table': out.push(<TableBlock key={i} block={b} ms={ms() !== undefined} ctx={ctx} />); break
216
+ case 'media': out.push(<MediaGrid key={i} items={b.items} ctx={ctx} />); break
217
+ case 'popout-def': skipList = true; break
218
+ default: break // footnote-def: its text is read by the notes
219
+ }
220
+ })
221
+ close('end')
222
+ return out
223
+ }
224
+
225
+ export default function MarkdownPage({ source = '', numbered = false, toc = 'frame', linkNotes = false, fit = 'cover', ratio, resolveLink = (u) => u, assetUrl = () => null }) {
226
+ const { fields, body } = useMemo(() => splitFrontmatter(source), [source])
227
+ const fm = Object.fromEntries(fields)
228
+ const parsed = useMemo(() => parseDocsMarkdown(body, { numbered }), [body, numbered])
229
+ const h1 = parsed.introBlocks.find((b) => b.type === 'heading1')
230
+ const title = h1?.content ?? fm.title ?? ''
231
+ const proj = numbered
232
+ const notes = useMemo(() => collectNotes(parsed, linkNotes), [parsed, linkNotes])
233
+ const ctx = { notes, proj, linkNotes, fit, ratio, resolveLink, assetUrl }
234
+
235
+ /* two sections with one heading (a research paper repeats its brand names) get one anchor each */
236
+ const seen = {}
237
+ const sections = parsed.sections.map((s) => { seen[s.id] = (seen[s.id] ?? 0) + 1; return seen[s.id] > 1 ? { ...s, id: `${s.id}-${seen[s.id]}` } : s })
238
+ const tocItems = sections.map((s) => ({ id: `a-${s.id}`, label: s.heading }))
239
+ const setToc = useContext(ShellTocContext)
240
+ const onFrame = toc === 'frame' && typeof setToc === 'function'
241
+ useLayoutEffect(() => {
242
+ if (!onFrame) return undefined
243
+ setToc(tocItems.length ? <DocsToc toc={tocItems} onNavigate={jumpTo} /> : null)
244
+ return () => setToc(null)
245
+ }, [onFrame, setToc, body]) // eslint-disable-line react-hooks/exhaustive-deps
246
+ const rail = toc === 'rail' || (toc === 'frame' && !onFrame)
247
+
248
+ return (
249
+ /* the page's inset is `.kol-page`, as site-hub's Sheet wears it — the frame paints the plane, the page its margins */
250
+ <div className={`kol-page${rail ? ' xl:pr-32' : ''}`}>
251
+ <article className={`kol-sheet${proj ? ' proj' : ''}`} style={{ maxWidth: 980 }}>
252
+ <h1>{title}</h1>
253
+ <Blocks blocks={parsed.introBlocks.filter((b) => b.type !== 'heading1')} sectionId="intro" ctx={ctx} />
254
+ {sections.map((s, i) => (
255
+ <section key={`${i}-${s.id}`} id={`a-${s.id}`}>
256
+ <h2>{s.num && <span className="num">{s.num}</span>}<Marks marks={s.marks} lead /><Inline tokens={s.tokens ?? [{ type: 'text', content: s.heading }]} ctx={ctx} /><Marks marks={s.marks} /></h2>
257
+ <Blocks blocks={s.blocks} sectionId={parsed.sections[i].id} sources={s.sources} ctx={ctx} />
258
+ </section>
259
+ ))}
260
+ </article>
261
+ {rail && tocItems.length > 1 && <DocsToc variant="rail" toc={tocItems} onNavigate={jumpTo} className="hidden xl:flex" />}
262
+ </div>
263
+ )
264
+ }
@@ -0,0 +1,27 @@
1
+ import { kindOf, extOf } from '@kolkrabbi/kol-component'
2
+
3
+ /* DOCUMENT FILES — the files beside a folder's markdown (DocumentIndexFilesAndIcons049, kol-website 2026-10-28, a point
4
+ * of the MarkdownPage ticket: *"a folder may also hold PDFs and images; the contents index lists them beside the md
5
+ * pages and opens them in the DS QuickLook, not as pages"*). The consumer globs them as URLs and hands the modules over:
6
+ *
7
+ * documentFiles(import.meta.glob('./brand/business/**\/*.{pdf,png,jpg,jpeg,webp,svg}', { query: '?url', import: 'default', eager: true }))
8
+ * → [{ id, title, group, file, ext, kind }] in the glob's (path) order
9
+ *
10
+ * id the file's stem without a `NN-` prefix (`02-business-plan.pdf` → `business-plan`)
11
+ * title the stem, unprefixed and titlecased (`Business Plan`)
12
+ * group the file's parent folder, titlecased and unprefixed (`frumkvaedi-2024/` → `Frumkvaedi 2024`); '' at the root
13
+ * file the served URL; ext its extension; kind the DS's (`kindOf`: pdf · image · video · audio · …)
14
+ *
15
+ * A row is what `DocumentIndex` draws as a card in its part and opens in Quick Look. `documentTabs` is the markdown half. */
16
+ const unprefix = (s) => s.replace(/^\d+[-_.]\s*/, '')
17
+ const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
18
+
19
+ export default function documentFiles(modules = {}, { root = '' } = {}) {
20
+ return Object.entries(modules).map(([path, url]) => {
21
+ const rel = root && path.startsWith(root) ? path.slice(root.length) : path
22
+ const parts = rel.replace(/^\.?\//, '').split('/')
23
+ const name = parts.pop()
24
+ const stem = name.replace(/\.[a-z0-9]+$/i, '')
25
+ return { id: unprefix(stem), title: titleCase(unprefix(stem)), group: parts.length ? titleCase(unprefix(parts[parts.length - 1])) : '', file: typeof url === 'string' ? url : '', ext: extOf(name), kind: kindOf({ key: name }) }
26
+ })
27
+ }
@@ -0,0 +1,29 @@
1
+ import { splitFrontmatter } from '@kolkrabbi/kol-markdown'
2
+
3
+ /* DOCUMENT TABS — a folder of markdown files as page rows (plan-2026-10-28-document W5.2; kol-website's MarkdownPage
4
+ * ticket: *"a folder of .md files rendered as pages"*). The consumer globs its folder and hands the modules over:
5
+ *
6
+ * documentTabs(import.meta.glob('./brand/foundation/**\/*.md', { query: '?raw', import: 'default', eager: true }))
7
+ * → [{ id, title, group, file, source }] in the glob's (path) order
8
+ *
9
+ * id the file's stem without a `NN-` prefix (`01-strategy.md` → `strategy`)
10
+ * title the frontmatter `title`, else the first `# heading`, else the stem
11
+ * group the file's parent folder, titlecased and unprefixed (`02-identity/` → `Identity`); '' at the root
12
+ * file the path as globbed; source the markdown
13
+ *
14
+ * A row is what `MarkdownPage` takes (`source`) and what a rail row or a `DocumentIndex` card is made from; `numbered`,
15
+ * `notes` and the rest are the consumer's to add per row, as kol-atlas's tab table does. */
16
+ const unprefix = (s) => s.replace(/^\d+[-_.]\s*/, '')
17
+ const titleCase = (s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
18
+
19
+ export default function documentTabs(modules = {}, { root = '' } = {}) {
20
+ return Object.entries(modules).map(([file, source]) => {
21
+ const rel = root && file.startsWith(root) ? file.slice(root.length) : file
22
+ const parts = rel.replace(/^\.?\//, '').split('/')
23
+ const stem = parts.pop().replace(/\.md$/i, '')
24
+ const { fields, body } = splitFrontmatter(typeof source === 'string' ? source : '')
25
+ const fm = Object.fromEntries(fields)
26
+ const h1 = /^#\s+(.+)$/m.exec(body)?.[1]?.trim()
27
+ return { id: unprefix(stem), title: fm.title || h1 || titleCase(unprefix(stem)), group: parts.length ? titleCase(unprefix(parts[parts.length - 1])) : '', file, source: typeof source === 'string' ? source : '' }
28
+ })
29
+ }
package/src/index.js CHANGED
@@ -27,3 +27,9 @@ export { default as PortalFooter } from './PortalFooter.jsx'
27
27
  export { default as ScrollToTop } from './ScrollToTop.jsx'
28
28
  export { default as PageHero, default as BrandHero, default as SubPageHero } from './PageHero.jsx'
29
29
  export { default as CategoryPage } from './CategoryPage.jsx'
30
+ /* the document page (plan-2026-10-28-document W5.2): a markdown file as `.kol-sheet`, the hover card for its links,
31
+ * a folder of files as page rows */
32
+ export { default as MarkdownPage } from './MarkdownPage.jsx'
33
+ export { default as LinkPreview } from './LinkPreview.jsx'
34
+ export { default as documentTabs } from './documentTabs.js'
35
+ export { default as documentFiles } from './documentFiles.js'