@kolkrabbi/kol-workshop 0.29.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.
@@ -1,374 +0,0 @@
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
- /* EXPORTED 2026-08-01. The props table needs the token producer so its cells
25
- * can render `backticked` code as a chip instead of printing the backticks.
26
- * It was module-private, which is why the table had no way to do it and the
27
- * gap read as "the table is bad" rather than "the parser was unreachable". */
28
- export const processInlineMarkdown = (text) => {
29
- if (!text) return []
30
-
31
- const tokens = []
32
- let remaining = text
33
-
34
- while (remaining.length > 0) {
35
- // Image: ![alt](src)
36
- const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
37
- if (imageMatch) {
38
- tokens.push({ type: 'image', alt: imageMatch[1], src: imageMatch[2] })
39
- remaining = remaining.slice(imageMatch[0].length)
40
- continue
41
- }
42
-
43
- // Wikilink: [[target]] / [[target|display]] / [[target#anchor|display]] (Obsidian).
44
- // Emitted as a .md link so it routes through the same resolveDocLink cross-link path.
45
- const wikiMatch = remaining.match(/^\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/)
46
- if (wikiMatch) {
47
- const target = wikiMatch[1].trim().replace(/\.md$/, '')
48
- const anchor = wikiMatch[2]
49
- ? wikiMatch[2].trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
50
- : ''
51
- const display = wikiMatch[3] ? wikiMatch[3].trim() : (wikiMatch[1].trim().split('/').pop())
52
- tokens.push({ type: 'link', text: display, url: `${target}.md${anchor ? `#${anchor}` : ''}` })
53
- remaining = remaining.slice(wikiMatch[0].length)
54
- continue
55
- }
56
-
57
- // Link: [text](url)
58
- const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/)
59
- if (linkMatch) {
60
- tokens.push({ type: 'link', text: linkMatch[1], url: linkMatch[2] })
61
- remaining = remaining.slice(linkMatch[0].length)
62
- continue
63
- }
64
-
65
- // Color swatch: {#rrggbb}
66
- const swatchMatch = remaining.match(/^\{(#[0-9a-fA-F]{6})\}/)
67
- if (swatchMatch) {
68
- tokens.push({ type: 'colorswatch', color: swatchMatch[1] })
69
- remaining = remaining.slice(swatchMatch[0].length)
70
- continue
71
- }
72
-
73
- // Inline code: `code`
74
- const codeMatch = remaining.match(/^`([^`]+)`/)
75
- if (codeMatch) {
76
- tokens.push({ type: 'code', content: codeMatch[1] })
77
- remaining = remaining.slice(codeMatch[0].length)
78
- continue
79
- }
80
-
81
- // Bold: **text** (must check before italic)
82
- const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/)
83
- if (boldMatch) {
84
- tokens.push({ type: 'bold', content: boldMatch[1] })
85
- remaining = remaining.slice(boldMatch[0].length)
86
- continue
87
- }
88
-
89
- // Italic: *text* (must not match list markers)
90
- const italicMatch = remaining.match(/^\*([^*\s][^*]*)\*/)
91
- if (italicMatch) {
92
- tokens.push({ type: 'italic', content: italicMatch[1] })
93
- remaining = remaining.slice(italicMatch[0].length)
94
- continue
95
- }
96
-
97
- // Text followed by a hashtag (but not inside markdown links)
98
- const textBeforeHashtag = remaining.match(/^([^#[]+)(#([a-z0-9-]+))/i)
99
- if (textBeforeHashtag && !remaining.startsWith('[')) {
100
- if (textBeforeHashtag[1]) {
101
- tokens.push({ type: 'text', content: textBeforeHashtag[1] })
102
- }
103
- tokens.push({ type: 'hashtag', tag: textBeforeHashtag[3].toLowerCase() })
104
- remaining = remaining.slice(textBeforeHashtag[1].length + textBeforeHashtag[2].length)
105
- continue
106
- }
107
-
108
- // Standalone hashtag at start of remaining text
109
- const hashtagMatch = remaining.match(/^#([a-z0-9-]+)/i)
110
- if (hashtagMatch) {
111
- tokens.push({ type: 'hashtag', tag: hashtagMatch[1].toLowerCase() })
112
- remaining = remaining.slice(hashtagMatch[0].length)
113
- continue
114
- }
115
-
116
- // Regular text - take until next special character or end
117
- const textMatch = remaining.match(/^([^*`[\]!]+)/)
118
- if (textMatch) {
119
- tokens.push({ type: 'text', content: textMatch[1] })
120
- remaining = remaining.slice(textMatch[0].length)
121
- continue
122
- }
123
-
124
- // If no match, take one character as text
125
- tokens.push({ type: 'text', content: remaining[0] })
126
- remaining = remaining.slice(1)
127
- }
128
-
129
- return tokens
130
- }
131
-
132
- /**
133
- * Parse markdown text into structured blocks.
134
- * Returns { sections, toc, introBlocks, inlineTags }.
135
- */
136
- export const parseDocsMarkdown = (markdown) => {
137
- if (!markdown) {
138
- return { sections: [], toc: [], introBlocks: [], inlineTags: [] }
139
- }
140
-
141
- const lines = markdown.split('\n')
142
- const sections = []
143
- const toc = []
144
- const introBlocks = []
145
-
146
- let current = null
147
- let inCode = false
148
- let inFrontmatter = false
149
- let paragraphBuffer = []
150
-
151
- const getTargetBlocks = () => (current ? current.blocks : introBlocks)
152
-
153
- const flushParagraph = () => {
154
- if (paragraphBuffer.length > 0) {
155
- const text = paragraphBuffer.join(' ').trim()
156
- if (text) {
157
- getTargetBlocks().push({
158
- type: 'paragraph',
159
- content: text,
160
- tokens: processInlineMarkdown(text),
161
- })
162
- }
163
- paragraphBuffer = []
164
- }
165
- }
166
-
167
- const startNewSection = (level, heading) => {
168
- flushParagraph()
169
-
170
- // H2 starts new section
171
- if (level === 2) {
172
- if (current) {
173
- sections.push(current)
174
- }
175
- const id = heading.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
176
- const tocLabel = heading.replace(/^[\d]+[a-z]?[.)]\s*/i, '')
177
- toc.push({ id, label: tocLabel, level: 2 })
178
- current = { heading, id, level: 2, blocks: [] }
179
- return
180
- }
181
-
182
- // H3/H4 added as blocks within current section (not in TOC)
183
- if (level === 3 || level === 4) {
184
- const id = heading.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
185
- if (current) {
186
- current.blocks.push({ type: level === 3 ? 'heading3' : 'heading4', id, content: heading })
187
- } else {
188
- // H3/H4 before any H2 goes to intro
189
- introBlocks.push({ type: level === 3 ? 'heading3' : 'heading4', id, content: heading })
190
- }
191
- }
192
- }
193
-
194
- lines.forEach((line, lineIndex) => {
195
- const trimmed = line.trim()
196
-
197
- // Code blocks - check first so content inside code blocks is preserved
198
- if (line.startsWith('```')) {
199
- if (inCode) {
200
- inCode = false
201
- } else {
202
- flushParagraph()
203
- const lang = line.replace(/^```/, '').trim()
204
- getTargetBlocks().push({ type: 'code', lang, lines: [] })
205
- inCode = true
206
- }
207
- return
208
- }
209
-
210
- // If inside a code block, add line as-is (preserves ---, #, etc.)
211
- if (inCode) {
212
- const blocks = getTargetBlocks()
213
- const lastBlock = blocks[blocks.length - 1]
214
- if (lastBlock && lastBlock.type === 'code') {
215
- lastBlock.lines.push(line)
216
- }
217
- return
218
- }
219
-
220
- // Handle frontmatter (skip it) - only when not in code block
221
- if (trimmed === '---') {
222
- if (lineIndex === 0) {
223
- inFrontmatter = true
224
- return
225
- }
226
- if (inFrontmatter) {
227
- inFrontmatter = false
228
- return
229
- }
230
- // Otherwise it's a divider
231
- flushParagraph()
232
- getTargetBlocks().push({ type: 'divider' })
233
- return
234
- }
235
-
236
- if (inFrontmatter) {
237
- return
238
- }
239
-
240
- // H1 - Title (add to intro, not sections)
241
- if (line.startsWith('# ')) {
242
- flushParagraph()
243
- introBlocks.push({ type: 'heading1', content: line.replace(/^#\s+/, '') })
244
- return
245
- }
246
-
247
- // H2 - Main sections
248
- if (line.startsWith('## ')) {
249
- startNewSection(2, line.replace(/^##\s+/, ''))
250
- return
251
- }
252
-
253
- // H3 - Sub-sections
254
- if (line.startsWith('### ')) {
255
- startNewSection(3, line.replace(/^###\s+/, ''))
256
- return
257
- }
258
-
259
- // H4 - Sub-sub-sections
260
- if (line.startsWith('#### ')) {
261
- startNewSection(4, line.replace(/^####\s+/, ''))
262
- return
263
- }
264
-
265
- // Empty line
266
- if (trimmed === '') {
267
- flushParagraph()
268
- return
269
- }
270
-
271
- // Unordered list
272
- const unorderedMatch = line.match(/^[-*]\s+(.*)/)
273
- if (unorderedMatch) {
274
- flushParagraph()
275
- const blocks = getTargetBlocks()
276
- const lastBlock = blocks[blocks.length - 1]
277
- const itemContent = unorderedMatch[1]
278
- if (lastBlock && lastBlock.type === 'list' && !lastBlock.ordered) {
279
- lastBlock.items.push({ content: itemContent, tokens: processInlineMarkdown(itemContent) })
280
- } else {
281
- blocks.push({
282
- type: 'list',
283
- ordered: false,
284
- items: [{ content: itemContent, tokens: processInlineMarkdown(itemContent) }],
285
- })
286
- }
287
- return
288
- }
289
-
290
- // Ordered list
291
- const orderedMatch = line.match(/^\d+\.\s+(.*)/)
292
- if (orderedMatch) {
293
- flushParagraph()
294
- const blocks = getTargetBlocks()
295
- const lastBlock = blocks[blocks.length - 1]
296
- const itemContent = orderedMatch[1]
297
- if (lastBlock && lastBlock.type === 'list' && lastBlock.ordered) {
298
- lastBlock.items.push({ content: itemContent, tokens: processInlineMarkdown(itemContent) })
299
- } else {
300
- blocks.push({
301
- type: 'list',
302
- ordered: true,
303
- items: [{ content: itemContent, tokens: processInlineMarkdown(itemContent) }],
304
- })
305
- }
306
- return
307
- }
308
-
309
- // Blockquote
310
- if (line.startsWith('>')) {
311
- flushParagraph()
312
- const content = line.replace(/^>\s?/, '')
313
- getTargetBlocks().push({ type: 'blockquote', content, tokens: processInlineMarkdown(content) })
314
- return
315
- }
316
-
317
- // Table row (starts with |)
318
- if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
319
- flushParagraph()
320
- const blocks = getTargetBlocks()
321
- const lastBlock = blocks[blocks.length - 1]
322
-
323
- /* Split on UNESCAPED pipes only, then unescape: Obsidian tables carry
324
- * `[[target\|display]]` wikilinks, and a naive split('|') exploded the
325
- * link across two cells and left literal `\` in the text — the reason
326
- * hub INDEX pages (tables of wikilinks) rendered as junk (wave-4). */
327
- const cells = trimmed
328
- .slice(1, -1)
329
- .split(/(?<!\\)\|/)
330
- .map((cell) => cell.trim().replace(/\\\|/g, '|'))
331
-
332
- // Separator row (|---|---|)
333
- const isSeparator = cells.every((cell) => /^:?-+:?$/.test(cell))
334
-
335
- if (isSeparator) {
336
- if (lastBlock && lastBlock.type === 'table') {
337
- lastBlock.hasSeparator = true
338
- }
339
- return
340
- }
341
-
342
- if (lastBlock && lastBlock.type === 'table') {
343
- if (!lastBlock.hasSeparator) {
344
- lastBlock.headers = cells
345
- } else {
346
- lastBlock.rows.push(cells.map((cell) => ({ content: cell, tokens: processInlineMarkdown(cell) })))
347
- }
348
- } else {
349
- blocks.push({ type: 'table', headers: cells, rows: [], hasSeparator: false })
350
- }
351
- return
352
- }
353
-
354
- // Regular text line - add to paragraph buffer
355
- paragraphBuffer.push(line)
356
- })
357
-
358
- flushParagraph()
359
-
360
- if (current) {
361
- sections.push(current)
362
- }
363
-
364
- // Extract hashtags from all content
365
- const allContent = [
366
- ...introBlocks.map((b) => b.content || ''),
367
- ...sections.flatMap((s) => [s.heading, ...s.blocks.map((b) => b.content || '')]),
368
- ].join(' ')
369
- const inlineTags = extractHashtags(allContent)
370
-
371
- return { sections, toc, introBlocks, inlineTags }
372
- }
373
-
374
- export default parseDocsMarkdown
@@ -1,36 +0,0 @@
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
- }
@@ -1,75 +0,0 @@
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
- }