@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 +5 -3
- package/src/LinkPreview.jsx +90 -0
- package/src/MarkdownPage.jsx +264 -0
- package/src/documentFiles.js +27 -0
- package/src/documentTabs.js +29 -0
- package/src/index.js +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "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-
|
|
26
|
-
"@kolkrabbi/kol-
|
|
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 (``) — 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'
|