@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-workshop",
3
- "version": "0.29.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.68.1",
20
- "@kolkrabbi/kol-framework": ">=0.23.0",
21
- "@kolkrabbi/kol-theme": ">=0.51.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.220.0",
29
- "@kolkrabbi/kol-icons": "^0.27.1",
30
- "@kolkrabbi/kol-framework": "^0.44.0",
31
- "@kolkrabbi/kol-theme": "^0.148.0"
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",
@@ -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 '../engine/parse-markdown.js'
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 '../engine'
8
- import { buildTagCounts } from '../engine/tags.js'
9
- import { fileLabel } from '../engine/doc-helpers.js'
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
- /* `#main` is the scroll container — the shell is `fixed inset-0` with its own
26
- * scroll regions, so a viewport-rooted observer never fires. Same call the
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: '#main' })
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) },
@@ -1,21 +1,8 @@
1
1
  /**
2
- * The pure docs engine — zero dependencies, no React, no Vite.
3
- * Everything here is a plain function over strings/data.
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 { parseDocsMarkdown, extractHashtags, processInlineMarkdown } 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
- 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
@@ -13,4 +13,5 @@ export * from './docs/index.js'
13
13
  export * from './tags/index.js'
14
14
  export * from './exhibit/index.js'
15
15
  export * from './compositions/index.js'
16
+ export * from './search/index.js'
16
17
  export * from './engine/index.js'
@@ -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, no chevron
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
- * - No chevron at L1 (user ruling 2026-08-01) — the section still collapses
37
- * and expands on click, the glyph is simply not drawn. L2 keeps its
38
- * chevron: it is the rung where a caret means "there is a subtree here".
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 IS AN L2 AFFORDANCE (user ruling 2026-08-01): "dont list counter
101
- * next to eyebrow category, just inside". A category eyebrow names a body of
102
- * material; the tally belongs to the groups inside it, not to the label over
103
- * them. So L1 drops a `count` on the floor rather than rendering it — the
104
- * rung refuses it, so no call site can put one back. */
105
- const countNode = level === 2 && count !== undefined && count !== null
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
- : null
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
- {countNode}
149
+ <span className="flex items-center gap-2">
150
+ {countNode}
151
+ {l1State}
152
+ </span>
142
153
  </>
143
154
  )
144
155