@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
|
@@ -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: 
|
|
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
|
package/src/engine/search.js
DELETED
|
@@ -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
|
-
}
|
package/src/engine/tags.js
DELETED
|
@@ -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
|
-
}
|