@kolkrabbi/kol-workshop 0.29.0 → 0.30.1
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 +10 -8
- package/src/docs/DocKit.jsx +1 -1
- package/src/docs/DocumentationReader.jsx +7 -7
- package/src/engine/index.js +6 -19
- package/src/exhibit/ExhibitSidebar.jsx +0 -1
- package/src/index.js +1 -0
- package/src/search/SearchPage.jsx +140 -0
- package/src/search/index.js +1 -0
- package/src/shell/RailSection.jsx +23 -12
- package/src/shell/ShellLayout.jsx +227 -158
- package/src/shell/ShellSidebar.jsx +25 -15
- package/src/shell/index.js +1 -1
- package/src/tags/TagGraph.jsx +4 -3
- package/src/tags/TagModeOverlay.jsx +3 -2
- package/src/engine/__check.mjs +0 -72
- package/src/engine/build-inventory.js +0 -85
- package/src/engine/doc-helpers.js +0 -202
- package/src/engine/frontmatter.js +0 -41
- package/src/engine/parse-markdown.js +0 -374
- package/src/engine/search.js +0 -36
- package/src/engine/tags.js +0 -75
|
@@ -24,13 +24,13 @@ const getChildPath = (child, basePath) => {
|
|
|
24
24
|
return p.startsWith('/') ? p : `${basePath}/${p}`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle }) => {
|
|
27
|
+
const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Navigation', labelTo, collapsed, onToggle, defaultCollapsed = false }) => {
|
|
28
28
|
const location = useLocation()
|
|
29
29
|
const normalizedPath = location.pathname.replace(/\/$/, '')
|
|
30
30
|
|
|
31
31
|
// Controlled mode: collapsed + onToggle from parent
|
|
32
32
|
// Uncontrolled mode: internal state
|
|
33
|
-
const [internalCollapsed, setInternalCollapsed] = useState(
|
|
33
|
+
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
|
|
34
34
|
const isControlled = collapsed !== undefined
|
|
35
35
|
const navCollapsed = isControlled ? collapsed : internalCollapsed
|
|
36
36
|
const handleToggle = isControlled ? onToggle : () => setInternalCollapsed(prev => !prev)
|
|
@@ -48,26 +48,34 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
48
48
|
return initial
|
|
49
49
|
})
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
51
|
+
/* THE RAIL FOLLOWS YOU (2026-09-28, user: "the sidebar doesnt highlight that
|
|
52
|
+
* part, which it probably should by closing/collapsing other categories").
|
|
53
|
+
* Arriving in a group opens it AND folds its siblings, so the open group is
|
|
54
|
+
* the one you are in. Opening another by hand still works until you move. */
|
|
55
|
+
const isRouteActive = (route) => {
|
|
56
|
+
const sectionPath = getSectionRootPath(route, basePath)
|
|
57
|
+
const own = sectionPath === basePath
|
|
58
|
+
? normalizedPath === basePath
|
|
59
|
+
: normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
|
|
60
|
+
return own || (route.children ?? []).some((c) => {
|
|
61
|
+
const cp = getChildPath(c, basePath).replace(/\/$/, '')
|
|
62
|
+
return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
|
|
61
63
|
})
|
|
64
|
+
}
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const active = routes.filter(isRouteActive)
|
|
67
|
+
if (!active.length) return
|
|
68
|
+
setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !active.includes(r)])))
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
70
|
}, [normalizedPath, routes, basePath])
|
|
63
71
|
|
|
64
72
|
const handleSectionClick = (route) => {
|
|
65
73
|
setCollapsedSections((prev) => ({ ...prev, [route.id]: !prev[route.id] }))
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
/*
|
|
69
|
-
*
|
|
70
|
-
|
|
76
|
+
/* The L1 count is every leaf row under it — RailSection shows it only while
|
|
77
|
+
* the section is folded (2026-09-28). */
|
|
78
|
+
const leafCount = routes.reduce((n, r) => n + (r.children?.length ? r.children.length : 1), 0)
|
|
71
79
|
return (
|
|
72
80
|
/* One rail layout, one class (2026-08-01). This was `space-y-4` against the
|
|
73
81
|
* right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
|
|
@@ -78,9 +86,11 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
|
|
|
78
86
|
level={1}
|
|
79
87
|
label={label}
|
|
80
88
|
to={labelTo}
|
|
89
|
+
count={leafCount}
|
|
81
90
|
collapsed={navCollapsed}
|
|
82
91
|
onToggle={handleToggle}
|
|
83
92
|
onNavigate={onNavigate}
|
|
93
|
+
icon={Icon}
|
|
84
94
|
>
|
|
85
95
|
<div className="shell-rail-stack-inner">
|
|
86
96
|
{routes.map((route) => {
|
package/src/shell/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* against.
|
|
8
8
|
*/
|
|
9
9
|
export { default as ShellLayout } from './ShellLayout.jsx'
|
|
10
|
-
export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext, ShellNavCollapsedContext, ShellContentWidthContext } from './ShellLayout.jsx'
|
|
10
|
+
export { ShellTocContext, ShellFullHeightContext, ShellTocCollapsedContext, ShellNavCollapsedContext, ShellContentWidthContext, SHELL_SCROLL_ROOT, usePageMeta, usePageMetaValue } from './ShellLayout.jsx'
|
|
11
11
|
export { default as ShellSidebar } from './ShellSidebar.jsx'
|
|
12
12
|
/* RailSection — THE rail ladder. Every rail header at every rung comes from
|
|
13
13
|
* here, in both rails; see the file header for why a class alone was not
|
package/src/tags/TagGraph.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef, useMemo, useState } from 'react'
|
|
2
2
|
import * as d3 from 'd3'
|
|
3
3
|
import { useNavigate } from 'react-router-dom'
|
|
4
|
-
import { getTagColor } from '
|
|
5
|
-
import {
|
|
4
|
+
import { getTagColor } from '@kolkrabbi/kol-markdown'
|
|
5
|
+
import { tagGraph } from '@kolkrabbi/kol-search'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* TagGraph - Obsidian-style tag graph
|
|
@@ -26,7 +26,8 @@ const TagGraph = ({ docs, activeTag, onTagClick, allDocs, tagHref = defaultTagHr
|
|
|
26
26
|
const sourceDocs = allDocs || docs
|
|
27
27
|
|
|
28
28
|
// Compute graph data: ALL tags with connections (pure — see engine/tags.js)
|
|
29
|
-
|
|
29
|
+
/* the engine's graph (kol-search `tagGraph`, 2026-09-29) over the docs' tags */
|
|
30
|
+
const graphData = useMemo(() => tagGraph(sourceDocs.map((d) => ({ id: d.id, tags: d.metadata?.tags ?? [] }))), [sourceDocs])
|
|
30
31
|
|
|
31
32
|
// Update dimensions on resize
|
|
32
33
|
useEffect(() => {
|
|
@@ -5,8 +5,9 @@ import TagPath from './TagPath.jsx'
|
|
|
5
5
|
import { useTagMode } from './TagModeContext.jsx'
|
|
6
6
|
import TagGraph from './TagGraph.jsx'
|
|
7
7
|
import RailRow from '../shell/RailRow.jsx'
|
|
8
|
-
import { extractDocNumber, cleanTitle
|
|
9
|
-
import {
|
|
8
|
+
import { extractDocNumber, cleanTitle } from '@kolkrabbi/kol-markdown'
|
|
9
|
+
import { matchSearchItems } from '@kolkrabbi/kol-search'
|
|
10
|
+
import { buildTagCounts } from '@kolkrabbi/kol-markdown'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* TagModeOverlay - the committed RESULTS view: documents that match, with tags
|
package/src/engine/__check.mjs
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Runnable self-check for the pure engine. `node src/engine/__check.mjs`
|
|
3
|
-
* Asserts the parser, frontmatter, inventory seam, and helpers hold.
|
|
4
|
-
*/
|
|
5
|
-
import assert from 'node:assert'
|
|
6
|
-
import {
|
|
7
|
-
parseDocsMarkdown,
|
|
8
|
-
parseFrontmatter,
|
|
9
|
-
buildInventory,
|
|
10
|
-
buildInventoryCounts,
|
|
11
|
-
getTagColor,
|
|
12
|
-
extractDocNumber,
|
|
13
|
-
} from './index.js'
|
|
14
|
-
|
|
15
|
-
const md = `---
|
|
16
|
-
title: Demo
|
|
17
|
-
status: draft
|
|
18
|
-
tags: [alpha, beta]
|
|
19
|
-
---
|
|
20
|
-
# Title
|
|
21
|
-
Intro paragraph with a #gamma tag.
|
|
22
|
-
## Section One
|
|
23
|
-
- item one
|
|
24
|
-
- item two
|
|
25
|
-
## Section Two
|
|
26
|
-
| A | B |
|
|
27
|
-
|---|---|
|
|
28
|
-
| 1 | 2 |
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
// --- parser ---
|
|
32
|
-
const parsed = parseDocsMarkdown(md)
|
|
33
|
-
assert.equal(parsed.toc.length, 2, 'two H2 sections in toc')
|
|
34
|
-
assert.equal(parsed.toc[0].id, 'section-one')
|
|
35
|
-
assert.ok(parsed.inlineTags.includes('gamma'), 'harvests inline #gamma')
|
|
36
|
-
assert.equal(parsed.introBlocks[0].type, 'heading1')
|
|
37
|
-
const s1 = parsed.sections[0]
|
|
38
|
-
assert.equal(s1.blocks[0].type, 'list')
|
|
39
|
-
assert.equal(s1.blocks[0].items.length, 2)
|
|
40
|
-
const s2 = parsed.sections[1]
|
|
41
|
-
assert.equal(s2.blocks[0].type, 'table')
|
|
42
|
-
assert.deepEqual(s2.blocks[0].headers, ['A', 'B'])
|
|
43
|
-
assert.equal(s2.blocks[0].rows.length, 1)
|
|
44
|
-
|
|
45
|
-
// --- frontmatter ---
|
|
46
|
-
const fm = parseFrontmatter(md)
|
|
47
|
-
assert.deepEqual(fm.tags, ['alpha', 'beta'], 'inline array tags')
|
|
48
|
-
assert.equal(fm.title, 'Demo')
|
|
49
|
-
assert.equal(fm.status, 'draft')
|
|
50
|
-
|
|
51
|
-
// --- inventory seam (module map, no Vite glob) ---
|
|
52
|
-
const inv = buildInventory({
|
|
53
|
-
'/x/docs/documentation/03-components/3.0.1-button.md': '---\ntags: [ui]\nstatus: stable\n---\n# Button\n',
|
|
54
|
-
'/x/docs/documentation/00-docs/index.md': '---\nstatus: stable\n---\n# Intro\n',
|
|
55
|
-
})
|
|
56
|
-
assert.equal(inv.length, 2)
|
|
57
|
-
const btn = inv.find((d) => d.id === '3.0.1-button')
|
|
58
|
-
assert.ok(btn, 'button doc discovered')
|
|
59
|
-
assert.equal(btn.title, 'Button')
|
|
60
|
-
assert.deepEqual(btn.metadata.tags, ['ui'])
|
|
61
|
-
assert.equal(inv.find((d) => d.file.endsWith('00-docs/index.md')).id, '00-docs-index', 'index.md id gets folder prefix')
|
|
62
|
-
const counts = buildInventoryCounts(inv)
|
|
63
|
-
assert.equal(counts.total, 2)
|
|
64
|
-
assert.equal(counts.statuses.stable, 2)
|
|
65
|
-
|
|
66
|
-
// --- helpers ---
|
|
67
|
-
assert.equal(extractDocNumber('0.0.1-writing'), '0.0.1')
|
|
68
|
-
assert.equal(extractDocNumber('04-pages-index'), '4.0.0')
|
|
69
|
-
assert.ok(['blue', 'teal', 'green', 'yellow', 'red', 'orange', 'purple', 'dark'].includes(getTagColor('ui')))
|
|
70
|
-
assert.equal(getTagColor('ui'), getTagColor('ui'), 'deterministic')
|
|
71
|
-
|
|
72
|
-
console.log('kol-workshop engine self-check: OK')
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { parseFrontmatter } from './frontmatter.js'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The doc inventory + counts, DECOUPLED from Vite.
|
|
5
|
-
*
|
|
6
|
-
* The monorepo original ran `import.meta.glob('@docs/...')` at module load,
|
|
7
|
-
* hard-binding it to Vite and the monorepo `/docs` path. Here the consumer
|
|
8
|
-
* supplies the raw-markdown module map instead — this is the content-injection
|
|
9
|
-
* seam that lets the set drop into any repo:
|
|
10
|
-
*
|
|
11
|
-
* const modules = import.meta.glob('/docs/ ** /*.md',
|
|
12
|
-
* { eager: true, query: '?raw', import: 'default' })
|
|
13
|
-
* const inventory = buildInventory(modules)
|
|
14
|
-
*
|
|
15
|
-
* @param {Record<string,string>} modules path → raw markdown string
|
|
16
|
-
*/
|
|
17
|
-
export function buildInventory(modules) {
|
|
18
|
-
const inventory = Object.entries(modules).map(([path, raw]) => {
|
|
19
|
-
const normalisedPath = path.replace(/^.*\/docs\/documentation\//, 'docs/documentation/')
|
|
20
|
-
const lines = raw.split(/\r?\n/)
|
|
21
|
-
const headingLine = lines.find((line) => line.startsWith('# ')) ?? ''
|
|
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
|
-
}
|
|
36
|
-
const metadata = parseFrontmatter(raw)
|
|
37
|
-
const filename = normalisedPath.split('/').pop() ?? ''
|
|
38
|
-
const parentFolder = normalisedPath.split('/').slice(-2, -1)[0] ?? ''
|
|
39
|
-
const baseId = filename.replace(/\.md$/, '')
|
|
40
|
-
|
|
41
|
-
// Make index.md / INDEX.md IDs unique by prefixing with parent folder
|
|
42
|
-
const id = baseId.toLowerCase() === 'index' ? `${parentFolder}-${baseId}` : baseId
|
|
43
|
-
|
|
44
|
-
const rawTitle = title || metadata.title || id
|
|
45
|
-
const cleanTitle = rawTitle
|
|
46
|
-
.replace(/^Design System\s*[-–—:]\s*/i, '')
|
|
47
|
-
.replace(/\bCheat Sheet\b/i, 'Quicklook')
|
|
48
|
-
|
|
49
|
-
return { id, file: normalisedPath, title: cleanTitle, metadata, headings, parentFolder }
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
/* Collision safety (2026-07-30): filename-only ids collide across folders in
|
|
53
|
-
* any real vault (02-icons/01-inventory vs 03-components/01-inventory — the
|
|
54
|
-
* React dup-key bug from the 07-29 vault experiment). A collided id gains
|
|
55
|
-
* its parent folder, same convention the index handling already uses. */
|
|
56
|
-
const counts = inventory.reduce((m, d) => (m[d.id] = (m[d.id] ?? 0) + 1, m), {})
|
|
57
|
-
for (const d of inventory) {
|
|
58
|
-
if (counts[d.id] > 1) d.id = `${d.parentFolder}-${d.id}`
|
|
59
|
-
}
|
|
60
|
-
for (const d of inventory) delete d.parentFolder
|
|
61
|
-
|
|
62
|
-
inventory.sort((a, b) => a.file.localeCompare(b.file))
|
|
63
|
-
return inventory
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Tally status / category / content-type across an inventory. */
|
|
67
|
-
export function buildInventoryCounts(inventory) {
|
|
68
|
-
return inventory.reduce(
|
|
69
|
-
(acc, doc) => {
|
|
70
|
-
acc.total += 1
|
|
71
|
-
const { metadata } = doc
|
|
72
|
-
if (metadata.status) {
|
|
73
|
-
acc.statuses[metadata.status] = (acc.statuses[metadata.status] ?? 0) + 1
|
|
74
|
-
}
|
|
75
|
-
if (metadata.category) {
|
|
76
|
-
acc.categories[metadata.category] = (acc.categories[metadata.category] ?? 0) + 1
|
|
77
|
-
}
|
|
78
|
-
if (metadata['content-type']) {
|
|
79
|
-
acc.contentTypes[metadata['content-type']] = (acc.contentTypes[metadata['content-type']] ?? 0) + 1
|
|
80
|
-
}
|
|
81
|
-
return acc
|
|
82
|
-
},
|
|
83
|
-
{ total: 0, statuses: {}, categories: {}, contentTypes: {} }
|
|
84
|
-
)
|
|
85
|
-
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared doc/tag helpers — pure, zero-dependency. Lifted verbatim.
|
|
3
|
-
*
|
|
4
|
-
* These bake in the KOL numbered-doc taxonomy (`categoryLabels`, version
|
|
5
|
-
* conventions, `kolkrabbiPages`). That's correct for a KOL package; a future
|
|
6
|
-
* consumer with a different scheme would parameterize these — not yet needed.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const capitalise = (value) =>
|
|
10
|
-
value ? value.charAt(0).toUpperCase() + value.slice(1) : value
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* fileLabel — a rail row's name, taken from the FILENAME (user ruling
|
|
14
|
-
* 2026-08-01).
|
|
15
|
-
*
|
|
16
|
-
* The tree used to label rows from the doc's H1, so `01-tokens.md` (opening
|
|
17
|
-
* `# Foundations — the token system`) rendered as "Foundations" — a row whose
|
|
18
|
-
* name appears nowhere in the folder, and two files whose H1s both began
|
|
19
|
-
* "Foundations" collapsed into two identical rows. A tree that mirrors a
|
|
20
|
-
* folder has to read the folder: what Finder shows is what the sidebar shows.
|
|
21
|
-
*
|
|
22
|
-
* 01-tokens.md → Tokens
|
|
23
|
-
* 04-layout-breakpoints.md → Layout breakpoints
|
|
24
|
-
*
|
|
25
|
-
* Titles still drive SEARCH and the page header; this is the tree label only.
|
|
26
|
-
*/
|
|
27
|
-
export const fileLabel = (file = '') => {
|
|
28
|
-
const base = file.split('/').pop()?.replace(/\.md$/i, '') ?? ''
|
|
29
|
-
const named = base.replace(/^\d+(\.\d+)*[-.]?\s*/, '')
|
|
30
|
-
if (!named) return capitalise(base)
|
|
31
|
-
return capitalise(named.replace(/[-_]+/g, ' ').trim())
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Index.md files (e.g. "00-metadata-index", "foundry-index") have no version dots
|
|
35
|
-
export const isIndexFile = (id) => id.endsWith('-index') && !id.includes('.')
|
|
36
|
-
|
|
37
|
-
export const extractDocNumber = (id) => {
|
|
38
|
-
if (isIndexFile(id)) {
|
|
39
|
-
// Main section index: "04-pages-index" → "4.0.0"
|
|
40
|
-
const folderMatch = id.match(/^(\d+)-[a-z-]+-index$/)
|
|
41
|
-
if (folderMatch) {
|
|
42
|
-
const major = folderMatch[1].replace(/^0/, '')
|
|
43
|
-
return `${major}.0.0`
|
|
44
|
-
}
|
|
45
|
-
return 'Index'
|
|
46
|
-
}
|
|
47
|
-
// "0.0.1-writing-guidelines" → "0.0.1"
|
|
48
|
-
const match = id.match(/^(\d+\.\d+\.\d+)/)
|
|
49
|
-
return match ? match[1] : id
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Main site pages that should have "Kolkrabbi" prefix
|
|
53
|
-
export const kolkrabbiPages = ['4.1.0', '4.2.0', '4.3.0', '4.4.0']
|
|
54
|
-
|
|
55
|
-
// Subsection prefixes based on version range
|
|
56
|
-
export const subsectionPrefixes = {
|
|
57
|
-
'4.6': 'Collections',
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const categoryLabels = {
|
|
61
|
-
0: 'Docs',
|
|
62
|
-
1: 'Foundation',
|
|
63
|
-
2: 'Design System',
|
|
64
|
-
3: 'Components',
|
|
65
|
-
4: 'Pages',
|
|
66
|
-
5: 'Workshop',
|
|
67
|
-
6: 'Foundry',
|
|
68
|
-
7: 'Research',
|
|
69
|
-
8: 'Operations',
|
|
70
|
-
9: 'CDN',
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export const cleanTitle = (title, id) => {
|
|
74
|
-
if (isIndexFile(id)) {
|
|
75
|
-
const folderMatch = id.match(/^(\d+)-([a-z-]+)-index$/)
|
|
76
|
-
if (folderMatch) {
|
|
77
|
-
const major = folderMatch[1].replace(/^0/, '')
|
|
78
|
-
return `${categoryLabels[major] || 'Section'} Index`
|
|
79
|
-
}
|
|
80
|
-
return 'Section Index'
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let cleaned = title.replace(/^\d+\.\d+\.\d+\s*/, '')
|
|
84
|
-
cleaned = cleaned.replace(/^[A-Za-z\s]+:\s*/, '')
|
|
85
|
-
/* SUBTITLE OFF (user ruling 2026-08-01): "dont Foundtation - the token
|
|
86
|
-
* system, just 'foundation'". A doc's `title` is written for the document —
|
|
87
|
-
* "Type classes — the two families and when to use which" — and a rail row
|
|
88
|
-
* is not the place to read a sentence: three of eight rows wrapped to two
|
|
89
|
-
* and three lines. Everything from the em/en dash on is the subtitle, so the
|
|
90
|
-
* row keeps the name and drops the explanation. The DOC's own title is
|
|
91
|
-
* untouched; this is the rail's label only.
|
|
92
|
-
* Spaced dash only — an unspaced one is a compound word, not a separator. */
|
|
93
|
-
cleaned = cleaned.replace(/\s+[—–]\s+.*$/, '')
|
|
94
|
-
cleaned = cleaned.trim() || title
|
|
95
|
-
|
|
96
|
-
const versionMatch = id.match(/^(\d+\.\d+)\.(\d+)/)
|
|
97
|
-
if (versionMatch) {
|
|
98
|
-
const majorMinor = versionMatch[1]
|
|
99
|
-
|
|
100
|
-
if (kolkrabbiPages.includes(`${majorMinor}.${versionMatch[2]}`)) {
|
|
101
|
-
return `Kolkrabbi ${cleaned}`
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const prefix = subsectionPrefixes[majorMinor]
|
|
105
|
-
if (prefix) {
|
|
106
|
-
const prefixPattern = new RegExp(`^(${prefix}|${prefix.replace(/s$/, '')})(\\s+|$)`, 'i')
|
|
107
|
-
cleaned = cleaned.replace(prefixPattern, '').trim()
|
|
108
|
-
const suffixPattern = new RegExp(`(\\s+|^)(${prefix}|${prefix.replace(/s$/, '')})$`, 'i')
|
|
109
|
-
cleaned = cleaned.replace(suffixPattern, '').trim()
|
|
110
|
-
return `${prefix} ${cleaned}`
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return cleaned
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const TAG_COLORS = ['blue', 'teal', 'green', 'yellow', 'red', 'orange', 'purple', 'dark']
|
|
118
|
-
|
|
119
|
-
/* Colour BY NAMESPACE, from the closed taxonomy in `.kol/docs-framework/
|
|
120
|
-
* 03-tag-taxonomy.md`. Ten top-level namespaces, ten stable assignments —
|
|
121
|
-
* so `domain/*` is always one colour and the eye can group by prefix.
|
|
122
|
-
*
|
|
123
|
-
* This used to hash the whole tag string into TAG_COLORS. That made the colour
|
|
124
|
-
* a function of spelling rather than meaning: `#domain/design-system` and
|
|
125
|
-
* `#domain/iconography` landed on different hues for no reason a reader could
|
|
126
|
-
* see, and renaming a tag silently recoloured it. The taxonomy existed the
|
|
127
|
-
* whole time and nothing consulted it. */
|
|
128
|
-
const NAMESPACE_COLORS = {
|
|
129
|
-
domain: 'blue',
|
|
130
|
-
pattern: 'purple',
|
|
131
|
-
provider: 'orange',
|
|
132
|
-
integration: 'teal',
|
|
133
|
-
project: 'green',
|
|
134
|
-
audience: 'yellow',
|
|
135
|
-
brand: 'red',
|
|
136
|
-
editor: 'teal',
|
|
137
|
-
framework: 'dark',
|
|
138
|
-
archive: 'dark',
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Stable fallback for tags outside the taxonomy — still deterministic, but
|
|
142
|
-
* only reached when a tag carries no known namespace. */
|
|
143
|
-
const hashColor = (s) => {
|
|
144
|
-
let hash = 0
|
|
145
|
-
for (let i = 0; i < s.length; i++) hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0
|
|
146
|
-
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export const getTagColor = (tag) => {
|
|
150
|
-
const namespace = String(tag).replace(/^#/, '').split('/')[0]
|
|
151
|
-
return NAMESPACE_COLORS[namespace] ?? hashColor(namespace)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Group docs by major version number.
|
|
155
|
-
*
|
|
156
|
-
* Three id dialects are grouped: dotted (`0.0.1-slug`), folder-index
|
|
157
|
-
* (`04-pages-index`), and PLAIN NUMBERED (`04-layout-breakpoints`) — the last
|
|
158
|
-
* one was previously unmatched, so a vault of `NN-slug.md` files produced a
|
|
159
|
-
* tree holding only its INDEX files and every real doc was search-only
|
|
160
|
-
* (fixed 2026-07-30). Multi-word folder names work too: the old
|
|
161
|
-
* `^(\d+)-[a-z]+-index$` rejected any hyphen, silently dropping ids like
|
|
162
|
-
* `09-ds-adoption-index`.
|
|
163
|
-
*/
|
|
164
|
-
export const groupDocsByMajor = (docs) => {
|
|
165
|
-
const groups = {}
|
|
166
|
-
docs.forEach((d) => {
|
|
167
|
-
const majorMatch = d.id.match(/^(\d+)\./)
|
|
168
|
-
const indexMatch = d.id.match(/^(\d+)-[a-z][a-z0-9-]*-index$/)
|
|
169
|
-
const plainMatch = d.id.match(/^(\d+)-/)
|
|
170
|
-
|
|
171
|
-
let major = null
|
|
172
|
-
if (majorMatch) {
|
|
173
|
-
major = majorMatch[1]
|
|
174
|
-
} else if (indexMatch) {
|
|
175
|
-
major = indexMatch[1].replace(/^0/, '')
|
|
176
|
-
} else if (plainMatch) {
|
|
177
|
-
major = plainMatch[1].replace(/^0(?=\d)/, '')
|
|
178
|
-
} else if (isIndexFile(d.id)) {
|
|
179
|
-
const titleMatch = d.title.match(/^(\d+)\./)
|
|
180
|
-
if (titleMatch) {
|
|
181
|
-
major = titleMatch[1]
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (major !== null) {
|
|
186
|
-
if (!groups[major]) {
|
|
187
|
-
groups[major] = []
|
|
188
|
-
}
|
|
189
|
-
groups[major].push(d)
|
|
190
|
-
}
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
Object.keys(groups).forEach((major) => {
|
|
194
|
-
groups[major].sort((a, b) => {
|
|
195
|
-
const aNum = extractDocNumber(a.id)
|
|
196
|
-
const bNum = extractDocNumber(b.id)
|
|
197
|
-
return aNum.localeCompare(bNum, undefined, { numeric: true })
|
|
198
|
-
})
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
return groups
|
|
202
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Handrolled YAML-subset frontmatter parser — no gray-matter / js-yaml.
|
|
3
|
-
* Handles `key: value`, block lists (` - item`), and inline `[a, b]` arrays.
|
|
4
|
-
* Keys are lowercased. Lifted verbatim from the monorepo.
|
|
5
|
-
*/
|
|
6
|
-
export const parseFrontmatter = (raw) => {
|
|
7
|
-
const metadata = {}
|
|
8
|
-
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
9
|
-
if (!match) return metadata
|
|
10
|
-
|
|
11
|
-
const lines = match[1].split(/\r?\n/)
|
|
12
|
-
for (let i = 0; i < lines.length; i++) {
|
|
13
|
-
const kvMatch = lines[i].match(/^([A-Za-z][A-Za-z0-9 -]*):\s*(.*)$/)
|
|
14
|
-
if (kvMatch) {
|
|
15
|
-
const key = kvMatch[1].toLowerCase()
|
|
16
|
-
const value = kvMatch[2].trim()
|
|
17
|
-
if (!value) {
|
|
18
|
-
// YAML block-list continuation lines ( - item)
|
|
19
|
-
const items = []
|
|
20
|
-
while (i + 1 < lines.length && lines[i + 1].match(/^\s+-\s+/)) {
|
|
21
|
-
i++
|
|
22
|
-
items.push(lines[i].replace(/^\s+-\s+/, '').trim())
|
|
23
|
-
}
|
|
24
|
-
metadata[key] = items.length ? items : ''
|
|
25
|
-
} else {
|
|
26
|
-
metadata[key] = value
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Parse tags array from YAML "[foo, bar, baz]" string into JS array
|
|
32
|
-
if (typeof metadata.tags === 'string' && metadata.tags.startsWith('[')) {
|
|
33
|
-
metadata.tags = metadata.tags
|
|
34
|
-
.slice(1, -1)
|
|
35
|
-
.split(',')
|
|
36
|
-
.map((t) => t.trim())
|
|
37
|
-
.filter(Boolean)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return metadata
|
|
41
|
-
}
|