@kolkrabbi/kol-workshop 0.28.0 → 0.30.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.
@@ -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(false)
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
- useEffect(() => {
52
- routes.forEach((route) => {
53
- const sectionPath = getSectionRootPath(route, basePath)
54
- const isActive =
55
- sectionPath === basePath
56
- ? normalizedPath === basePath
57
- : normalizedPath === sectionPath || normalizedPath.startsWith(sectionPath + '/')
58
- if (isActive) {
59
- setCollapsedSections((prev) => ({ ...prev, [route.id]: false }))
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
- /* No count at L1 — the eyebrow names a body of material and the tally lives
69
- * on the groups inside it (user ruling 2026-08-01). RailSection refuses one
70
- * at this rung, so there is nothing to compute here. */
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) => {
@@ -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
@@ -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 '../engine/index.js'
5
- import { buildTagCooccurrence } from '../engine/tags.js'
4
+ import { getTagColor } from '@kolkrabbi/kol-markdown'
5
+ import { buildTagCooccurrence } from '@kolkrabbi/kol-markdown'
6
6
 
7
7
  /**
8
8
  * TagGraph - Obsidian-style tag graph
@@ -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, matchSearchItems } from '../engine/index.js'
9
- import { buildTagCounts } from '../engine/tags.js'
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
@@ -99,7 +100,9 @@ const TagModeOverlay = () => {
99
100
  <div className="flex flex-col gap-4">
100
101
  {hasFilters && (
101
102
  <>
102
- <div className="flex items-center justify-between">
103
+ {/* INSET TO THE ROWS (user 2026-09-25: the button sat flush on the panel edge while the
104
+ * rows below start at `.shell-nav-item`'s 20px, 24px from 1600) */}
105
+ <div className="flex items-center justify-between pl-5 min-[1600px]:pl-6">
103
106
  {/* PRIMARY (user 2026-08-01). `outline quiet` is the
104
107
  * recessive rung — this is the one action in the body. */}
105
108
  <Button variant="primary" size="sm" onClick={clearTags}>
@@ -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
- }