@kolkrabbi/kol-workshop 0.1.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.
@@ -0,0 +1,132 @@
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
+ // Index.md files (e.g. "00-metadata-index", "foundry-index") have no version dots
13
+ export const isIndexFile = (id) => id.endsWith('-index') && !id.includes('.')
14
+
15
+ export const extractDocNumber = (id) => {
16
+ if (isIndexFile(id)) {
17
+ // Main section index: "04-pages-index" → "4.0.0"
18
+ const folderMatch = id.match(/^(\d+)-[a-z-]+-index$/)
19
+ if (folderMatch) {
20
+ const major = folderMatch[1].replace(/^0/, '')
21
+ return `${major}.0.0`
22
+ }
23
+ return 'Index'
24
+ }
25
+ // "0.0.1-writing-guidelines" → "0.0.1"
26
+ const match = id.match(/^(\d+\.\d+\.\d+)/)
27
+ return match ? match[1] : id
28
+ }
29
+
30
+ // Main site pages that should have "Kolkrabbi" prefix
31
+ export const kolkrabbiPages = ['4.1.0', '4.2.0', '4.3.0', '4.4.0']
32
+
33
+ // Subsection prefixes based on version range
34
+ export const subsectionPrefixes = {
35
+ '4.6': 'Collections',
36
+ }
37
+
38
+ export const categoryLabels = {
39
+ 0: 'Docs',
40
+ 1: 'Foundation',
41
+ 2: 'Design System',
42
+ 3: 'Components',
43
+ 4: 'Pages',
44
+ 5: 'Workshop',
45
+ 6: 'Foundry',
46
+ 7: 'Research',
47
+ 8: 'Operations',
48
+ 9: 'CDN',
49
+ }
50
+
51
+ export const cleanTitle = (title, id) => {
52
+ if (isIndexFile(id)) {
53
+ const folderMatch = id.match(/^(\d+)-([a-z-]+)-index$/)
54
+ if (folderMatch) {
55
+ const major = folderMatch[1].replace(/^0/, '')
56
+ return `${categoryLabels[major] || 'Section'} Index`
57
+ }
58
+ return 'Section Index'
59
+ }
60
+
61
+ let cleaned = title.replace(/^\d+\.\d+\.\d+\s*/, '')
62
+ cleaned = cleaned.replace(/^[A-Za-z\s]+:\s*/, '')
63
+ cleaned = cleaned.trim() || title
64
+
65
+ const versionMatch = id.match(/^(\d+\.\d+)\.(\d+)/)
66
+ if (versionMatch) {
67
+ const majorMinor = versionMatch[1]
68
+
69
+ if (kolkrabbiPages.includes(`${majorMinor}.${versionMatch[2]}`)) {
70
+ return `Kolkrabbi ${cleaned}`
71
+ }
72
+
73
+ const prefix = subsectionPrefixes[majorMinor]
74
+ if (prefix) {
75
+ const prefixPattern = new RegExp(`^(${prefix}|${prefix.replace(/s$/, '')})(\\s+|$)`, 'i')
76
+ cleaned = cleaned.replace(prefixPattern, '').trim()
77
+ const suffixPattern = new RegExp(`(\\s+|^)(${prefix}|${prefix.replace(/s$/, '')})$`, 'i')
78
+ cleaned = cleaned.replace(suffixPattern, '').trim()
79
+ return `${prefix} ${cleaned}`
80
+ }
81
+ }
82
+
83
+ return cleaned
84
+ }
85
+
86
+ const TAG_COLORS = ['blue', 'teal', 'green', 'yellow', 'red', 'orange', 'purple', 'dark']
87
+
88
+ export const getTagColor = (tag) => {
89
+ let hash = 0
90
+ for (let i = 0; i < tag.length; i++) {
91
+ hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
92
+ }
93
+ return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
94
+ }
95
+
96
+ /** Group docs by major version number. */
97
+ export const groupDocsByMajor = (docs) => {
98
+ const groups = {}
99
+ docs.forEach((d) => {
100
+ const majorMatch = d.id.match(/^(\d+)\./)
101
+ const indexMatch = d.id.match(/^(\d+)-[a-z]+-index$/)
102
+
103
+ let major = null
104
+ if (majorMatch) {
105
+ major = majorMatch[1]
106
+ } else if (indexMatch) {
107
+ major = indexMatch[1].replace(/^0/, '')
108
+ } else if (isIndexFile(d.id)) {
109
+ const titleMatch = d.title.match(/^(\d+)\./)
110
+ if (titleMatch) {
111
+ major = titleMatch[1]
112
+ }
113
+ }
114
+
115
+ if (major !== null) {
116
+ if (!groups[major]) {
117
+ groups[major] = []
118
+ }
119
+ groups[major].push(d)
120
+ }
121
+ })
122
+
123
+ Object.keys(groups).forEach((major) => {
124
+ groups[major].sort((a, b) => {
125
+ const aNum = extractDocNumber(a.id)
126
+ const bNum = extractDocNumber(b.id)
127
+ return aNum.localeCompare(bNum, undefined, { numeric: true })
128
+ })
129
+ })
130
+
131
+ return groups
132
+ }
@@ -0,0 +1,41 @@
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
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The pure docs engine — zero dependencies, no React, no Vite.
3
+ * Everything here is a plain function over strings/data.
4
+ */
5
+ export { parseDocsMarkdown, extractHashtags } from './parse-markdown.js'
6
+ export { parseFrontmatter } from './frontmatter.js'
7
+ export { buildInventory, buildInventoryCounts } from './build-inventory.js'
8
+ export { matchSearchItems } from './search.js'
9
+ export { buildTagCounts, buildTagCooccurrence } from './tags.js'
10
+ export {
11
+ capitalise,
12
+ isIndexFile,
13
+ extractDocNumber,
14
+ kolkrabbiPages,
15
+ subsectionPrefixes,
16
+ categoryLabels,
17
+ cleanTitle,
18
+ getTagColor,
19
+ groupDocsByMajor,
20
+ } from './doc-helpers.js'
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Handrolled markdown parser for documentation pages.
3
+ * Block-level + inline tokenizer — no remark/markdown-it/marked/MDX.
4
+ * Lifted verbatim from the monorepo, minus the React render layer
5
+ * (`renderInlineTokens` lives with the docs viewer in Phase 2).
6
+ *
7
+ * `parseDocsMarkdown(md)` → { sections, toc, introBlocks, inlineTags }
8
+ */
9
+
10
+ /** Extract #hashtags from markdown content. */
11
+ export const extractHashtags = (content) => {
12
+ // Strip markdown link targets (…) to avoid matching anchor fragments as tags
13
+ const cleaned = content.replace(/\([^)]*\)/g, '')
14
+ const regex = /#([a-z0-9-]+)/gi
15
+ const matches = cleaned.match(regex)
16
+ return matches ? [...new Set(matches.map((t) => t.slice(1).toLowerCase()))] : []
17
+ }
18
+
19
+ /**
20
+ * Process inline markdown within text content.
21
+ * Supports: bold, italic, inline code, links, images, color swatches, hashtags.
22
+ * Returns a token array; the render layer turns tokens into elements.
23
+ */
24
+ const processInlineMarkdown = (text) => {
25
+ if (!text) return []
26
+
27
+ const tokens = []
28
+ let remaining = text
29
+
30
+ while (remaining.length > 0) {
31
+ // Image: ![alt](src)
32
+ const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
33
+ if (imageMatch) {
34
+ tokens.push({ type: 'image', alt: imageMatch[1], src: imageMatch[2] })
35
+ remaining = remaining.slice(imageMatch[0].length)
36
+ continue
37
+ }
38
+
39
+ // Link: [text](url)
40
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/)
41
+ if (linkMatch) {
42
+ tokens.push({ type: 'link', text: linkMatch[1], url: linkMatch[2] })
43
+ remaining = remaining.slice(linkMatch[0].length)
44
+ continue
45
+ }
46
+
47
+ // Color swatch: {#rrggbb}
48
+ const swatchMatch = remaining.match(/^\{(#[0-9a-fA-F]{6})\}/)
49
+ if (swatchMatch) {
50
+ tokens.push({ type: 'colorswatch', color: swatchMatch[1] })
51
+ remaining = remaining.slice(swatchMatch[0].length)
52
+ continue
53
+ }
54
+
55
+ // Inline code: `code`
56
+ const codeMatch = remaining.match(/^`([^`]+)`/)
57
+ if (codeMatch) {
58
+ tokens.push({ type: 'code', content: codeMatch[1] })
59
+ remaining = remaining.slice(codeMatch[0].length)
60
+ continue
61
+ }
62
+
63
+ // Bold: **text** (must check before italic)
64
+ const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/)
65
+ if (boldMatch) {
66
+ tokens.push({ type: 'bold', content: boldMatch[1] })
67
+ remaining = remaining.slice(boldMatch[0].length)
68
+ continue
69
+ }
70
+
71
+ // Italic: *text* (must not match list markers)
72
+ const italicMatch = remaining.match(/^\*([^*\s][^*]*)\*/)
73
+ if (italicMatch) {
74
+ tokens.push({ type: 'italic', content: italicMatch[1] })
75
+ remaining = remaining.slice(italicMatch[0].length)
76
+ continue
77
+ }
78
+
79
+ // Text followed by a hashtag (but not inside markdown links)
80
+ const textBeforeHashtag = remaining.match(/^([^#[]+)(#([a-z0-9-]+))/i)
81
+ if (textBeforeHashtag && !remaining.startsWith('[')) {
82
+ if (textBeforeHashtag[1]) {
83
+ tokens.push({ type: 'text', content: textBeforeHashtag[1] })
84
+ }
85
+ tokens.push({ type: 'hashtag', tag: textBeforeHashtag[3].toLowerCase() })
86
+ remaining = remaining.slice(textBeforeHashtag[1].length + textBeforeHashtag[2].length)
87
+ continue
88
+ }
89
+
90
+ // Standalone hashtag at start of remaining text
91
+ const hashtagMatch = remaining.match(/^#([a-z0-9-]+)/i)
92
+ if (hashtagMatch) {
93
+ tokens.push({ type: 'hashtag', tag: hashtagMatch[1].toLowerCase() })
94
+ remaining = remaining.slice(hashtagMatch[0].length)
95
+ continue
96
+ }
97
+
98
+ // Regular text - take until next special character or end
99
+ const textMatch = remaining.match(/^([^*`[\]!]+)/)
100
+ if (textMatch) {
101
+ tokens.push({ type: 'text', content: textMatch[1] })
102
+ remaining = remaining.slice(textMatch[0].length)
103
+ continue
104
+ }
105
+
106
+ // If no match, take one character as text
107
+ tokens.push({ type: 'text', content: remaining[0] })
108
+ remaining = remaining.slice(1)
109
+ }
110
+
111
+ return tokens
112
+ }
113
+
114
+ /**
115
+ * Parse markdown text into structured blocks.
116
+ * Returns { sections, toc, introBlocks, inlineTags }.
117
+ */
118
+ export const parseDocsMarkdown = (markdown) => {
119
+ if (!markdown) {
120
+ return { sections: [], toc: [], introBlocks: [], inlineTags: [] }
121
+ }
122
+
123
+ const lines = markdown.split('\n')
124
+ const sections = []
125
+ const toc = []
126
+ const introBlocks = []
127
+
128
+ let current = null
129
+ let inCode = false
130
+ let inFrontmatter = false
131
+ let paragraphBuffer = []
132
+
133
+ const getTargetBlocks = () => (current ? current.blocks : introBlocks)
134
+
135
+ const flushParagraph = () => {
136
+ if (paragraphBuffer.length > 0) {
137
+ const text = paragraphBuffer.join(' ').trim()
138
+ if (text) {
139
+ getTargetBlocks().push({
140
+ type: 'paragraph',
141
+ content: text,
142
+ tokens: processInlineMarkdown(text),
143
+ })
144
+ }
145
+ paragraphBuffer = []
146
+ }
147
+ }
148
+
149
+ const startNewSection = (level, heading) => {
150
+ flushParagraph()
151
+
152
+ // H2 starts new section
153
+ if (level === 2) {
154
+ if (current) {
155
+ sections.push(current)
156
+ }
157
+ const id = heading.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
158
+ const tocLabel = heading.replace(/^[\d]+[a-z]?[.)]\s*/i, '')
159
+ toc.push({ id, label: tocLabel, level: 2 })
160
+ current = { heading, id, level: 2, blocks: [] }
161
+ return
162
+ }
163
+
164
+ // H3/H4 added as blocks within current section (not in TOC)
165
+ if (level === 3 || level === 4) {
166
+ const id = heading.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
167
+ if (current) {
168
+ current.blocks.push({ type: level === 3 ? 'heading3' : 'heading4', id, content: heading })
169
+ } else {
170
+ // H3/H4 before any H2 goes to intro
171
+ introBlocks.push({ type: level === 3 ? 'heading3' : 'heading4', id, content: heading })
172
+ }
173
+ }
174
+ }
175
+
176
+ lines.forEach((line, lineIndex) => {
177
+ const trimmed = line.trim()
178
+
179
+ // Code blocks - check first so content inside code blocks is preserved
180
+ if (line.startsWith('```')) {
181
+ if (inCode) {
182
+ inCode = false
183
+ } else {
184
+ flushParagraph()
185
+ const lang = line.replace(/^```/, '').trim()
186
+ getTargetBlocks().push({ type: 'code', lang, lines: [] })
187
+ inCode = true
188
+ }
189
+ return
190
+ }
191
+
192
+ // If inside a code block, add line as-is (preserves ---, #, etc.)
193
+ if (inCode) {
194
+ const blocks = getTargetBlocks()
195
+ const lastBlock = blocks[blocks.length - 1]
196
+ if (lastBlock && lastBlock.type === 'code') {
197
+ lastBlock.lines.push(line)
198
+ }
199
+ return
200
+ }
201
+
202
+ // Handle frontmatter (skip it) - only when not in code block
203
+ if (trimmed === '---') {
204
+ if (lineIndex === 0) {
205
+ inFrontmatter = true
206
+ return
207
+ }
208
+ if (inFrontmatter) {
209
+ inFrontmatter = false
210
+ return
211
+ }
212
+ // Otherwise it's a divider
213
+ flushParagraph()
214
+ getTargetBlocks().push({ type: 'divider' })
215
+ return
216
+ }
217
+
218
+ if (inFrontmatter) {
219
+ return
220
+ }
221
+
222
+ // H1 - Title (add to intro, not sections)
223
+ if (line.startsWith('# ')) {
224
+ flushParagraph()
225
+ introBlocks.push({ type: 'heading1', content: line.replace(/^#\s+/, '') })
226
+ return
227
+ }
228
+
229
+ // H2 - Main sections
230
+ if (line.startsWith('## ')) {
231
+ startNewSection(2, line.replace(/^##\s+/, ''))
232
+ return
233
+ }
234
+
235
+ // H3 - Sub-sections
236
+ if (line.startsWith('### ')) {
237
+ startNewSection(3, line.replace(/^###\s+/, ''))
238
+ return
239
+ }
240
+
241
+ // H4 - Sub-sub-sections
242
+ if (line.startsWith('#### ')) {
243
+ startNewSection(4, line.replace(/^####\s+/, ''))
244
+ return
245
+ }
246
+
247
+ // Empty line
248
+ if (trimmed === '') {
249
+ flushParagraph()
250
+ return
251
+ }
252
+
253
+ // Unordered list
254
+ const unorderedMatch = line.match(/^[-*]\s+(.*)/)
255
+ if (unorderedMatch) {
256
+ flushParagraph()
257
+ const blocks = getTargetBlocks()
258
+ const lastBlock = blocks[blocks.length - 1]
259
+ const itemContent = unorderedMatch[1]
260
+ if (lastBlock && lastBlock.type === 'list' && !lastBlock.ordered) {
261
+ lastBlock.items.push({ content: itemContent, tokens: processInlineMarkdown(itemContent) })
262
+ } else {
263
+ blocks.push({
264
+ type: 'list',
265
+ ordered: false,
266
+ items: [{ content: itemContent, tokens: processInlineMarkdown(itemContent) }],
267
+ })
268
+ }
269
+ return
270
+ }
271
+
272
+ // Ordered list
273
+ const orderedMatch = line.match(/^\d+\.\s+(.*)/)
274
+ if (orderedMatch) {
275
+ flushParagraph()
276
+ const blocks = getTargetBlocks()
277
+ const lastBlock = blocks[blocks.length - 1]
278
+ const itemContent = orderedMatch[1]
279
+ if (lastBlock && lastBlock.type === 'list' && lastBlock.ordered) {
280
+ lastBlock.items.push({ content: itemContent, tokens: processInlineMarkdown(itemContent) })
281
+ } else {
282
+ blocks.push({
283
+ type: 'list',
284
+ ordered: true,
285
+ items: [{ content: itemContent, tokens: processInlineMarkdown(itemContent) }],
286
+ })
287
+ }
288
+ return
289
+ }
290
+
291
+ // Blockquote
292
+ if (line.startsWith('>')) {
293
+ flushParagraph()
294
+ const content = line.replace(/^>\s?/, '')
295
+ getTargetBlocks().push({ type: 'blockquote', content, tokens: processInlineMarkdown(content) })
296
+ return
297
+ }
298
+
299
+ // Table row (starts with |)
300
+ if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
301
+ flushParagraph()
302
+ const blocks = getTargetBlocks()
303
+ const lastBlock = blocks[blocks.length - 1]
304
+
305
+ const cells = trimmed
306
+ .slice(1, -1)
307
+ .split('|')
308
+ .map((cell) => cell.trim())
309
+
310
+ // Separator row (|---|---|)
311
+ const isSeparator = cells.every((cell) => /^:?-+:?$/.test(cell))
312
+
313
+ if (isSeparator) {
314
+ if (lastBlock && lastBlock.type === 'table') {
315
+ lastBlock.hasSeparator = true
316
+ }
317
+ return
318
+ }
319
+
320
+ if (lastBlock && lastBlock.type === 'table') {
321
+ if (!lastBlock.hasSeparator) {
322
+ lastBlock.headers = cells
323
+ } else {
324
+ lastBlock.rows.push(cells.map((cell) => ({ content: cell, tokens: processInlineMarkdown(cell) })))
325
+ }
326
+ } else {
327
+ blocks.push({ type: 'table', headers: cells, rows: [], hasSeparator: false })
328
+ }
329
+ return
330
+ }
331
+
332
+ // Regular text line - add to paragraph buffer
333
+ paragraphBuffer.push(line)
334
+ })
335
+
336
+ flushParagraph()
337
+
338
+ if (current) {
339
+ sections.push(current)
340
+ }
341
+
342
+ // Extract hashtags from all content
343
+ const allContent = [
344
+ ...introBlocks.map((b) => b.content || ''),
345
+ ...sections.flatMap((s) => [s.heading, ...s.blocks.map((b) => b.content || '')]),
346
+ ].join(' ')
347
+ const inlineTags = extractHashtags(allContent)
348
+
349
+ return { sections, toc, introBlocks, inlineTags }
350
+ }
351
+
352
+ export default parseDocsMarkdown
@@ -0,0 +1,36 @@
1
+ /**
2
+ * matchSearchItems — the pure substring-match predicate extracted from
3
+ * ShellSearchOverlay. Case-insensitive `includes` across each item's
4
+ * label / tags / headings / keywords, returning the matched items annotated
5
+ * with `matchedHeading` and `matchedKeyword` for the overlay's subtext rows.
6
+ *
7
+ * Match semantics (unchanged from the overlay):
8
+ * - an item survives if the query hits ANY of label / tags / headings / keywords
9
+ * - `matchedHeading` = the first heading that matched, else null
10
+ * - `matchedKeyword` = the first keyword that matched, but ONLY surfaced when
11
+ * the label and headings did NOT match (a keyword-only reason-for-inclusion)
12
+ *
13
+ * Pure JS, zero dependencies.
14
+ *
15
+ * @param {Array<Object>} items items shaped { label, tags?, headings?, keywords? }
16
+ * @param {string} query raw query string
17
+ * @returns {Array<Object>} filtered items, each spread with matchedHeading/matchedKeyword
18
+ */
19
+ export function matchSearchItems(items, query) {
20
+ if (!query || query.length === 0) return []
21
+ const q = query.toLowerCase()
22
+ return (items || [])
23
+ .map((item) => {
24
+ const labelMatch = item.label.toLowerCase().includes(q)
25
+ const tagMatch = (item.tags || []).some((tag) => tag.toLowerCase().includes(q))
26
+ const headingMatch = (item.headings || []).find((h) => h.toLowerCase().includes(q))
27
+ const keywordMatch = (item.keywords || []).find((k) => k.toLowerCase().includes(q))
28
+ if (!labelMatch && !tagMatch && !headingMatch && !keywordMatch) return null
29
+ return {
30
+ ...item,
31
+ matchedHeading: headingMatch || null,
32
+ matchedKeyword: !labelMatch && !headingMatch && keywordMatch ? keywordMatch : null,
33
+ }
34
+ })
35
+ .filter(Boolean)
36
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pure tag aggregation over a docs inventory — zero dependencies, no React, no d3.
3
+ *
4
+ * Extracted from TagModeOverlay (`buildTagCounts`) and TagGraph
5
+ * (`buildTagCooccurrence`) so the counting/co-occurrence math is testable in
6
+ * isolation and the components stay presentational. Each takes the docs
7
+ * `inventory` (array of `{ id, metadata: { tags: string[] } }`) and returns
8
+ * plain data.
9
+ *
10
+ * Case handling is deliberately NOT unified — it preserves the two call sites'
11
+ * original behaviour:
12
+ * - buildTagCounts keeps tags exact-case (the overlay's tag list)
13
+ * - buildTagCooccurrence lowercases tags (the graph's node ids)
14
+ */
15
+
16
+ /**
17
+ * Count docs per tag, tags kept exact-case. Returns `[{ tag, count }]` sorted
18
+ * by count descending.
19
+ */
20
+ export const buildTagCounts = (inventory = []) => {
21
+ const counts = {}
22
+ inventory.forEach((d) => {
23
+ if (Array.isArray(d.metadata?.tags)) {
24
+ d.metadata.tags.forEach((t) => {
25
+ counts[t] = (counts[t] || 0) + 1
26
+ })
27
+ }
28
+ })
29
+ return Object.entries(counts)
30
+ .map(([tag, count]) => ({ tag, count }))
31
+ .sort((a, b) => b.count - a.count)
32
+ }
33
+
34
+ /**
35
+ * Build the tag co-occurrence graph. Tag ids are lowercased. Nodes are all
36
+ * tags with their doc counts (sorted desc); edges connect any two tags that
37
+ * share at least one doc, weighted by the number of shared docs.
38
+ *
39
+ * Returns `{ nodes: [{ id, count, type: 'tag' }], edges: [{ source, target, weight }] }`.
40
+ */
41
+ export const buildTagCooccurrence = (inventory = []) => {
42
+ const tagCounts = {}
43
+ const tagToDocs = {} // tag -> Set of doc ids
44
+
45
+ inventory.forEach((doc) => {
46
+ const tags = doc.metadata?.tags || []
47
+ tags.forEach((tag) => {
48
+ const tagLower = tag.toLowerCase()
49
+ tagCounts[tagLower] = (tagCounts[tagLower] || 0) + 1
50
+ if (!tagToDocs[tagLower]) {
51
+ tagToDocs[tagLower] = new Set()
52
+ }
53
+ tagToDocs[tagLower].add(doc.id)
54
+ })
55
+ })
56
+
57
+ const nodes = Object.entries(tagCounts)
58
+ .map(([tag, count]) => ({ id: tag, count, type: 'tag' }))
59
+ .sort((a, b) => b.count - a.count)
60
+
61
+ const edges = []
62
+ const tagList = Object.keys(tagToDocs)
63
+ for (let i = 0; i < tagList.length; i++) {
64
+ for (let j = i + 1; j < tagList.length; j++) {
65
+ const tagA = tagList[i]
66
+ const tagB = tagList[j]
67
+ const commonDocs = [...tagToDocs[tagA]].filter((id) => tagToDocs[tagB].has(id))
68
+ if (commonDocs.length > 0) {
69
+ edges.push({ source: tagA, target: tagB, weight: commonDocs.length })
70
+ }
71
+ }
72
+ }
73
+
74
+ return { nodes, edges }
75
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @kolkrabbi/kol-workshop — the KOL workshop / docs system.
3
+ *
4
+ * Aggregates everything the package now owns: the shell composition
5
+ * (ShellLayout + ShellSidebar + their contexts), the docs viewer, the tag
6
+ * system, the example compositions, and the pure React-free engine. Duplicated
7
+ * chrome (ShellHeader, ShellDrawer, SearchInput, ShellSearchOverlay, DocsToc)
8
+ * ships from @kolkrabbi/kol-framework + @kolkrabbi/kol-component — consume it
9
+ * from there, not from here.
10
+ */
11
+ export * from './shell/index.js'
12
+ export * from './docs/index.js'
13
+ export * from './tags/index.js'
14
+ export * from './compositions/index.js'
15
+ export * from './engine/index.js'