@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-workshop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL workshop/docs system — a handrolled markdown engine, search, tag system (incl. a d3 tag graph), docs viewer, and docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}; content is consumer-injected (the package never globs docs).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,22 +13,24 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"d3": "^7.9.0",
|
|
16
|
+
"@kolkrabbi/kol-search": "^0.2.0",
|
|
17
|
+
"@kolkrabbi/kol-markdown": "^0.1.1",
|
|
16
18
|
"@kolkrabbi/kol-brand": "^0.1.3"
|
|
17
19
|
},
|
|
18
20
|
"peerDependencies": {
|
|
19
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
20
|
-
"@kolkrabbi/kol-framework": ">=0.
|
|
21
|
-
"@kolkrabbi/kol-theme": ">=0.
|
|
21
|
+
"@kolkrabbi/kol-component": ">=0.228.0",
|
|
22
|
+
"@kolkrabbi/kol-framework": ">=0.45.0",
|
|
23
|
+
"@kolkrabbi/kol-theme": ">=0.155.0",
|
|
22
24
|
"@kolkrabbi/kol-icons": ">=0.18.0",
|
|
23
25
|
"react": "^18.3.0 || ^19.0.0",
|
|
24
26
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
25
27
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
|
-
"@kolkrabbi/kol-component": "^0.
|
|
29
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
30
|
-
"@kolkrabbi/kol-
|
|
31
|
-
"@kolkrabbi/kol-
|
|
30
|
+
"@kolkrabbi/kol-component": "^0.229.0",
|
|
31
|
+
"@kolkrabbi/kol-icons": "^0.29.0",
|
|
32
|
+
"@kolkrabbi/kol-theme": "^0.156.0",
|
|
33
|
+
"@kolkrabbi/kol-framework": "^0.45.0"
|
|
32
34
|
},
|
|
33
35
|
"files": [
|
|
34
36
|
"src",
|
package/src/docs/DocKit.jsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { Table } from '@kolkrabbi/kol-component'
|
|
14
14
|
import { renderInlineTokens } from './render-tokens.jsx'
|
|
15
|
-
import { processInlineMarkdown } from '
|
|
15
|
+
import { processInlineMarkdown } from '@kolkrabbi/kol-markdown'
|
|
16
16
|
|
|
17
17
|
export function DocHeader({ eyebrow, title, lede, children }) {
|
|
18
18
|
return (
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { CodeBlock, Divider, Icon, Table, useScrollSpy } from '@kolkrabbi/kol-component'
|
|
4
|
-
import { ShellTocContext, RightRail } from '../shell'
|
|
4
|
+
import { ShellTocContext, RightRail, SHELL_SCROLL_ROOT } from '../shell'
|
|
5
5
|
import { useTagMode } from '../tags'
|
|
6
6
|
import TagPath from '../tags/TagPath.jsx'
|
|
7
|
-
import { parseDocsMarkdown, isIndexFile } from '
|
|
8
|
-
import { buildTagCounts } from '
|
|
9
|
-
import { fileLabel } from '
|
|
7
|
+
import { parseDocsMarkdown, isIndexFile } from '@kolkrabbi/kol-markdown'
|
|
8
|
+
import { buildTagCounts } from '@kolkrabbi/kol-markdown'
|
|
9
|
+
import { fileLabel } from '@kolkrabbi/kol-markdown'
|
|
10
10
|
import DocsHeader from './DocsHeader.jsx'
|
|
11
11
|
import DocsArticle from './DocsArticle.jsx'
|
|
12
12
|
import DocsFrontmatter from './DocsFrontmatter.jsx'
|
|
@@ -22,10 +22,10 @@ import { renderInlineTokens } from './render-tokens.jsx'
|
|
|
22
22
|
const DocReaderSidebar = ({ toc, allTags, related, docId, docsIndexHref, componentsHref, docFilePath, topTags = [] }) => {
|
|
23
23
|
const navigate = useNavigate()
|
|
24
24
|
const { openTagMode } = useTagMode()
|
|
25
|
-
/*
|
|
26
|
-
* scroll
|
|
25
|
+
/* The shell's one scroll region is the observer root — the shell is `fixed inset-0` with its own
|
|
26
|
+
* scroll region, so a viewport-rooted observer never fires. Same call the
|
|
27
27
|
* other rail makes; the rail is handed the answer, never a second observer. */
|
|
28
|
-
const activeId = useScrollSpy((toc ?? []).map((h) => h.id), { root:
|
|
28
|
+
const activeId = useScrollSpy((toc ?? []).map((h) => h.id), { root: SHELL_SCROLL_ROOT })
|
|
29
29
|
|
|
30
30
|
const actions = [
|
|
31
31
|
{ id: 'back', label: 'Back', icon: <Icon name="arrow-left" size={14} />, onClick: () => navigate(-1) },
|
package/src/engine/index.js
CHANGED
|
@@ -1,21 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* `@kolkrabbi/kol-workshop/engine` — kept as a re-export since 0.30.0. The engine moved to the
|
|
3
|
+
* engine tier: the markdown half is `@kolkrabbi/kol-markdown`, the matcher `@kolkrabbi/kol-search`.
|
|
4
|
+
* Import from those; this subpath and the root barrel's engine names stay so kolkrabbi.io
|
|
5
|
+
* resolves until it moves.
|
|
4
6
|
*/
|
|
5
|
-
export
|
|
6
|
-
export {
|
|
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
|
-
fileLabel,
|
|
19
|
-
getTagColor,
|
|
20
|
-
groupDocsByMajor,
|
|
21
|
-
} from './doc-helpers.js'
|
|
7
|
+
export * from '@kolkrabbi/kol-markdown'
|
|
8
|
+
export { matchSearchItems } from '@kolkrabbi/kol-search'
|
|
@@ -79,7 +79,6 @@ export default function ExhibitSidebar({
|
|
|
79
79
|
type="button"
|
|
80
80
|
className="shell-sidebar-action kol-mono-14 text-body"
|
|
81
81
|
onClick={() => navigator.clipboard?.writeText(window.location.pathname)}
|
|
82
|
-
title="Copy page path to clipboard"
|
|
83
82
|
>
|
|
84
83
|
<Icon name="copy" size={14} />
|
|
85
84
|
Copy path
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Fragment, useMemo } from 'react'
|
|
2
|
+
import { Link, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { Button, Input, Pill } from '@kolkrabbi/kol-component'
|
|
4
|
+
import { createIndex, search, parseQuery } from '@kolkrabbi/kol-search'
|
|
5
|
+
import { DocHeader, DocSection } from '../docs/DocKit.jsx'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SearchPage — THE results page (2026-09-28). Enter in the shell's palette
|
|
9
|
+
* lands here with `?q=`; so does a tag clicked anywhere (`#tag`). The WHOLE
|
|
10
|
+
* state is the query string in the URL — scope and facet picks are written back
|
|
11
|
+
* into it as `in:` · `kind:` · `cat:` · `tag:` tokens — so Back returns to the
|
|
12
|
+
* same results, a result link is an ordinary navigation, and a search can be
|
|
13
|
+
* shared as a link.
|
|
14
|
+
*
|
|
15
|
+
* It replaced two surfaces that each did half the job: the palette's in-place
|
|
16
|
+
* tag browser and a page whose groups were whatever `sectionLabel` each
|
|
17
|
+
* source happened to emit.
|
|
18
|
+
*
|
|
19
|
+
* @param {Array} items kol-search items: { id, title, href, kind?, space?, category?, tags?, headings?, keywords?, description?, date? }
|
|
20
|
+
* @param {Array} [spaces] the scope row, `[{ value, label }]` in header order — `value` is an item's `space`
|
|
21
|
+
* @param {Array} [facets] `[[field, label]]` shown as filter rows (default kind · category · tags)
|
|
22
|
+
* @param {number} [limit=60]
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_FACETS = [['kind', 'Kind'], ['category', 'Category'], ['tags', 'Tags']]
|
|
25
|
+
const PREFIX = { space: 'in', kind: 'kind', category: 'cat', tags: 'tag' }
|
|
26
|
+
const token = (field, value) => `${PREFIX[field] ?? field}:${/\s/.test(value) ? `"${value}"` : value}`
|
|
27
|
+
|
|
28
|
+
function Highlight({ text, ranges }) {
|
|
29
|
+
if (!ranges?.length) return text
|
|
30
|
+
const out = []
|
|
31
|
+
let at = 0
|
|
32
|
+
ranges.forEach(([s, e], i) => {
|
|
33
|
+
if (s > at) out.push(<Fragment key={`t${i}`}>{text.slice(at, s)}</Fragment>)
|
|
34
|
+
out.push(<span key={`m${i}`} className="underline decoration-2 underline-offset-4">{text.slice(s, e)}</span>)
|
|
35
|
+
at = e
|
|
36
|
+
})
|
|
37
|
+
if (at < text.length) out.push(<Fragment key="rest">{text.slice(at)}</Fragment>)
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const readToken = (t) => (t.kind === 'term'
|
|
42
|
+
? `${t.negate ? 'not ' : ''}“${t.value}”`
|
|
43
|
+
: t.kind === 'date'
|
|
44
|
+
? `${t.field} ${t.value}`
|
|
45
|
+
: `${t.negate ? 'not ' : ''}${t.field === 'tags' ? 'tag' : t.field}: ${t.value}${t.via === 'smart' ? ' (from the word)' : ''}`)
|
|
46
|
+
|
|
47
|
+
export default function SearchPage({ items = [], spaces = [], facets = DEFAULT_FACETS, limit = 60 }) {
|
|
48
|
+
const [params, setParams] = useSearchParams()
|
|
49
|
+
const q = params.get('q') ?? ''
|
|
50
|
+
const index = useMemo(() => createIndex(items), [items])
|
|
51
|
+
const out = useMemo(() => search(index, q, { limit }), [index, q, limit])
|
|
52
|
+
|
|
53
|
+
const setQ = (next) => setParams(next.trim() ? { q: next } : {}, { replace: true })
|
|
54
|
+
/* a pick adds its token; picking it again takes the token back out */
|
|
55
|
+
const toggleToken = (field, value) => {
|
|
56
|
+
const tok = token(field, value)
|
|
57
|
+
const parts = q.split(/\s+/).filter(Boolean)
|
|
58
|
+
const has = parts.includes(tok)
|
|
59
|
+
setQ((has ? parts.filter((p) => p !== tok) : [...parts, tok]).join(' '))
|
|
60
|
+
}
|
|
61
|
+
const removeToken = (raw) => setQ(q.split(/\s+/).filter((p) => p && p !== raw).join(' '))
|
|
62
|
+
const inSpace = parseQuery(q).filters.space ?? []
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex flex-col gap-10">
|
|
66
|
+
<DocHeader
|
|
67
|
+
eyebrow="Search"
|
|
68
|
+
title="Search"
|
|
69
|
+
lede={q ? `${out.total} result${out.total === 1 ? '' : 's'} for “${q}”.` : 'Everything in the site — components, docs, blocks, sets.'}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<div className="flex flex-col gap-4">
|
|
73
|
+
<Input
|
|
74
|
+
value={q}
|
|
75
|
+
onChange={(e) => setQ(e.target.value)}
|
|
76
|
+
placeholder="Search — try atom, tag:pattern/input, in:docs, -legacy"
|
|
77
|
+
iconLeft="search"
|
|
78
|
+
size="lg"
|
|
79
|
+
aria-label="Search query"
|
|
80
|
+
autoFocus
|
|
81
|
+
/>
|
|
82
|
+
{spaces.length > 0 && (
|
|
83
|
+
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Scope">
|
|
84
|
+
<Button variant="ghost" size="sm" pressed={!inSpace.length} onClick={() => setQ(q.split(/\s+/).filter((p) => p && !/^in:/.test(p)).join(' '))}>Everything</Button>
|
|
85
|
+
{spaces.map((sp) => (
|
|
86
|
+
<Button key={sp.value} variant="ghost" size="sm" pressed={inSpace.includes(sp.value)} onClick={() => toggleToken('space', sp.value)}>
|
|
87
|
+
{sp.label}
|
|
88
|
+
</Button>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
{out.query.tokens.length > 0 && (
|
|
93
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
94
|
+
<span className="kol-doc-eyebrow">Read as</span>
|
|
95
|
+
{out.query.tokens.map((t, i) => (
|
|
96
|
+
<button key={`${t.raw}-${i}`} type="button" onClick={() => removeToken(t.raw)} aria-label={`Remove ${t.raw}`} className="cursor-pointer">
|
|
97
|
+
<Pill variant={t.kind === 'term' ? 'secondary' : 'primary'} size="md">{readToken(t)} ×</Pill>
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{facets.map(([field, label]) => {
|
|
105
|
+
const values = (out.facets[field] ?? []).slice(0, 16)
|
|
106
|
+
if (!values.length) return null
|
|
107
|
+
return (
|
|
108
|
+
<DocSection key={field} id={`facet-${field}`} title={label}>
|
|
109
|
+
<div className="flex flex-wrap gap-2">
|
|
110
|
+
{values.map((f) => (
|
|
111
|
+
<Button key={f.value} variant="ghost" size="sm" pressed={f.selected} onClick={() => toggleToken(field, f.value)}>
|
|
112
|
+
{f.value}<span className="ml-1.5 opacity-60">{f.count}</span>
|
|
113
|
+
</Button>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
</DocSection>
|
|
117
|
+
)
|
|
118
|
+
})}
|
|
119
|
+
|
|
120
|
+
<DocSection id="results" title={`Results (${out.total})`}>
|
|
121
|
+
{out.results.length === 0 ? (
|
|
122
|
+
<p className="kol-doc-body">{q ? 'Nothing matches. Remove a token above, or try fewer words.' : 'Type to search.'}</p>
|
|
123
|
+
) : (
|
|
124
|
+
<ol className="flex flex-col">
|
|
125
|
+
{out.results.map((r) => (
|
|
126
|
+
<li key={r.item.id} className="border-t border-fg-08 first:border-t-0">
|
|
127
|
+
<Link to={r.item.href} className="flex flex-col gap-1 py-3 hover:bg-fg-04">
|
|
128
|
+
<span className="kol-doc-body text-emphasis"><Highlight text={r.item.title} ranges={r.highlights.title} /></span>
|
|
129
|
+
<span className="kol-helper-12 text-subtle">{[r.item.kind, r.item.category, r.item.space, r.item.date].filter(Boolean).join(' · ')}</span>
|
|
130
|
+
{r.item.description && <span className="kol-doc-body">{r.item.description}</span>}
|
|
131
|
+
</Link>
|
|
132
|
+
</li>
|
|
133
|
+
))}
|
|
134
|
+
</ol>
|
|
135
|
+
)}
|
|
136
|
+
{out.total > out.results.length && <p className="kol-helper-12 text-subtle">{out.total - out.results.length} more — narrow the query.</p>}
|
|
137
|
+
</DocSection>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as SearchPage } from './SearchPage.jsx'
|
|
@@ -18,7 +18,7 @@ import { Link } from 'react-router-dom'
|
|
|
18
18
|
*
|
|
19
19
|
* THE LADDER
|
|
20
20
|
*
|
|
21
|
-
* L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet,
|
|
21
|
+
* L1 section `.shell-sidebar-toggle` + kol-doc-eyebrow quiet, state glyph at the end
|
|
22
22
|
* L2 group `.shell-nav-group-header` + kol-mono-14 louder, chevron
|
|
23
23
|
*
|
|
24
24
|
* L2 carries NO colour class: `.shell-nav-group-header` owns weight 500 and
|
|
@@ -33,9 +33,12 @@ import { Link } from 'react-router-dom'
|
|
|
33
33
|
* either rail is literally the same string.
|
|
34
34
|
* - The COUNT is placed by the rung, not by the caller. You cannot put it in
|
|
35
35
|
* the wrong place because you never write the span.
|
|
36
|
-
* -
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* - L1 SAYS WHETHER IT IS FOLDED (2026-09-28, reversing the 2026-08-01 "no
|
|
37
|
+
* chevron at L1"): a collapsed eyebrow read as an EMPTY category — "it just
|
|
38
|
+
* has an interaction, but no states or indicator of folded content". The
|
|
39
|
+
* state glyph sits at the row's END (the eyebrow's text keeps the rail's
|
|
40
|
+
* left edge), and a collapsed L1 shows how much it is hiding. L2 keeps its
|
|
41
|
+
* leading chevron: the rung where a caret means "there is a subtree here".
|
|
39
42
|
* - The box (padding + margin) belongs to the rung class alone. Nothing here
|
|
40
43
|
* sets y-spacing inline; see the eyebrow-box law in kol-components-workshop.css.
|
|
41
44
|
*
|
|
@@ -97,14 +100,19 @@ export default function RailSection({
|
|
|
97
100
|
/>
|
|
98
101
|
) : null
|
|
99
102
|
|
|
100
|
-
/* THE COUNT
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
const countNode = level === 2 &&
|
|
103
|
+
/* THE COUNT. L2 always carries its own. L1 carries one ONLY while folded
|
|
104
|
+
* (2026-09-28): open, the groups inside show their tallies and a second one
|
|
105
|
+
* on the eyebrow is noise (the 2026-08-01 ruling's point, kept); folded,
|
|
106
|
+
* there is nothing else on screen to say the category holds anything. */
|
|
107
|
+
const hasCount = count !== undefined && count !== null
|
|
108
|
+
const countNode = level === 2 && hasCount
|
|
106
109
|
? <span className="kol-mono-14 text-subtle">({count})</span>
|
|
107
|
-
:
|
|
110
|
+
: level === 1 && collapsible && isCollapsed && hasCount
|
|
111
|
+
? <span className="text-subtle">({count})</span>
|
|
112
|
+
: null
|
|
113
|
+
const l1State = level === 1 && collapsible && IconComponent ? (
|
|
114
|
+
<IconComponent name="chevron-right" size={12} className={`transition-transform text-subtle ${isCollapsed ? '' : 'rotate-90'}`} />
|
|
115
|
+
) : null
|
|
108
116
|
|
|
109
117
|
/* L1 EYEBROW IS ONE GESTURE (user ruling 2026-08-09, repealing the
|
|
110
118
|
* 2026-08-01 label-door at L1): the whole row toggles BOTH ways — a label
|
|
@@ -138,7 +146,10 @@ export default function RailSection({
|
|
|
138
146
|
{level === 2 && !chevron && <span aria-hidden="true" style={{ width: 12 }} />}
|
|
139
147
|
{labelNode}
|
|
140
148
|
</span>
|
|
141
|
-
|
|
149
|
+
<span className="flex items-center gap-2">
|
|
150
|
+
{countNode}
|
|
151
|
+
{l1State}
|
|
152
|
+
</span>
|
|
142
153
|
</>
|
|
143
154
|
)
|
|
144
155
|
|