@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.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @kolkrabbi/kol-workshop
2
+
3
+ The KOL **workshop / docs system**, lifted from the monorepo `apps/web` into the design system.
4
+
5
+ A handrolled documentation engine — no `remark`, no `gray-matter`, no `fuse.js` — plus (in later phases) the docs shell, search, and a tag system including a d3 tag graph. Sits above the other four UI packages and consumes them.
6
+
7
+ ## Status
8
+
9
+ **Phase 1 — the pure engine (this release).** React-free, zero-dependency:
10
+
11
+ | Import | What |
12
+ | --- | --- |
13
+ | `parseDocsMarkdown(md)` | block + inline markdown → `{ sections, toc, introBlocks, inlineTags }` |
14
+ | `parseFrontmatter(raw)` | YAML-subset frontmatter → object (inline arrays + block lists) |
15
+ | `buildInventory(modules)` | a raw-markdown module map → a doc inventory (see the seam below) |
16
+ | `buildInventoryCounts(inv)` | status / category / content-type tallies |
17
+ | `getTagColor`, `extractDocNumber`, `cleanTitle`, `groupDocsByMajor`, … | doc/tag helpers |
18
+
19
+ ```js
20
+ import { parseDocsMarkdown, buildInventory } from '@kolkrabbi/kol-workshop/engine'
21
+ ```
22
+
23
+ ### Content-injection seam
24
+
25
+ The package is **Vite-agnostic** — it never globs your docs. The consumer supplies the raw-markdown module map:
26
+
27
+ ```js
28
+ const modules = import.meta.glob('/docs/**/*.md', { eager: true, query: '?raw', import: 'default' })
29
+ const inventory = buildInventory(modules)
30
+ ```
31
+
32
+ ## Roadmap
33
+
34
+ - **Phase 2** — the shell + docs viewer + tag-mode components, KOL-conformed (Button / Icon-v1 / no text-transform), chrome CSS moved into `@kolkrabbi/kol-theme`.
35
+ - **Phase 3** — content-injection seam extended to route + search config.
36
+ - **Phase 4** — flip `private` off, publish, repoint the monorepo `apps/web` onto it, delete the local copies.
37
+
38
+ Self-check: `node src/engine/__check.mjs`.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@kolkrabbi/kol-workshop",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "KOL workshop/docs system — handrolled markdown engine, search, tag system, and the docs shell. Sits above @kolkrabbi/kol-{theme,icons,component,framework}. Private until the conformance sweep (Phase 4) ships it.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js",
12
+ "./engine": "./src/engine/index.js"
13
+ },
14
+ "dependencies": {
15
+ "d3": "^7.9.0",
16
+ "@kolkrabbi/kol-component": "0.7.0",
17
+ "@kolkrabbi/kol-framework": "0.3.2",
18
+ "@kolkrabbi/kol-icons": "0.5.0",
19
+ "@kolkrabbi/kol-theme": "0.7.0"
20
+ },
21
+ "peerDependencies": {
22
+ "react": "^18.3.0 || ^19.0.0",
23
+ "react-dom": "^18.3.0 || ^19.0.0",
24
+ "react-router-dom": "^6.0.0 || ^7.0.0"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "keywords": [
31
+ "kol",
32
+ "kolkrabbi",
33
+ "design-system",
34
+ "docs",
35
+ "markdown",
36
+ "workshop"
37
+ ],
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
41
+ "directory": "packages/workshop"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,89 @@
1
+ import { useLocation, Link } from 'react-router-dom'
2
+ import { Icon } from '@kolkrabbi/kol-component'
3
+
4
+ /**
5
+ * WorkshopDefaultSidebar — EXAMPLE composition. The contextual right rail:
6
+ * given the current route it shows sibling pages, the section's repo links, and
7
+ * quick actions. Decoupled from app singletons via props:
8
+ *
9
+ * routes — nav tree (was `WORKSHOP_ROUTES`): [{ id, label, path, children }],
10
+ * where a child may carry `links: { live, repo }`.
11
+ * basePath — mount point of the workshop (drives route matching + hrefs).
12
+ */
13
+
14
+ const WorkshopDefaultSidebar = ({ routes = [], basePath = '/workshop' }) => {
15
+ const location = useLocation()
16
+ const stripped = location.pathname.startsWith(basePath)
17
+ ? location.pathname.slice(basePath.length).replace(/^\//, '')
18
+ : location.pathname
19
+ const firstSegment = stripped.split('/')[0]
20
+
21
+ const parentRoute = routes.find(r => r.path === firstSegment)
22
+ const siblings = parentRoute?.children || []
23
+ const currentChild = siblings.find(c => `${basePath}/${c.path}` === location.pathname)
24
+ const links = currentChild?.links
25
+
26
+ return (
27
+ <div className="space-y-4">
28
+ {siblings.length > 1 && (
29
+ <div>
30
+ <div className="shell-sidebar-label">{parentRoute.label}</div>
31
+ <nav className="space-y-0">
32
+ {siblings.map(child => (
33
+ <Link
34
+ key={child.id}
35
+ to={`${basePath}/${child.path}`}
36
+ className={`shell-sidebar-link block ${
37
+ location.pathname === `${basePath}/${child.path}` ? 'active' : ''
38
+ }`}
39
+ >
40
+ {child.label}
41
+ </Link>
42
+ ))}
43
+ </nav>
44
+ </div>
45
+ )}
46
+
47
+ {links && (
48
+ <div>
49
+ <div className="shell-sidebar-label">Repository</div>
50
+ <div className="space-y-1">
51
+ {links.live && (
52
+ <a href={links.live} target="_blank" rel="noopener noreferrer" className="shell-sidebar-action">
53
+ <Icon name="external-link" size={14} />
54
+ Live site
55
+ </a>
56
+ )}
57
+ {links.repo && (
58
+ <a href={links.repo} target="_blank" rel="noopener noreferrer" className="shell-sidebar-action">
59
+ <Icon name="external-link" size={14} />
60
+ GitHub
61
+ </a>
62
+ )}
63
+ </div>
64
+ </div>
65
+ )}
66
+
67
+ <div>
68
+ <div className="shell-sidebar-label">Quick actions</div>
69
+ <div className="space-y-1">
70
+ <Link to={basePath} className="shell-sidebar-action">
71
+ <Icon name="layout" size={14} />
72
+ Workshop home
73
+ </Link>
74
+ <button
75
+ type="button"
76
+ className="shell-sidebar-action"
77
+ onClick={() => navigator.clipboard.writeText(window.location.pathname)}
78
+ title="Copy page path to clipboard"
79
+ >
80
+ <Icon name="copy" size={14} />
81
+ Copy path
82
+ </button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ export default WorkshopDefaultSidebar
@@ -0,0 +1,180 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { Link, useLocation } from 'react-router-dom'
3
+ import { ShellSidebar } from '../shell'
4
+ import { Icon } from '@kolkrabbi/kol-component'
5
+ import {
6
+ extractDocNumber,
7
+ cleanTitle,
8
+ categoryLabels,
9
+ groupDocsByMajor,
10
+ } from '../engine'
11
+
12
+ /**
13
+ * WorkshopSidebar — EXAMPLE composition. This is the app-specific glue that
14
+ * wires the KOL shell + docs engine into a product's primary navigation.
15
+ * Consumers copy this file and adapt it; it is intentionally decoupled from
16
+ * any app singleton via props:
17
+ *
18
+ * routes — nav tree (was `WORKSHOP_ROUTES`): [{ id, label, path, children }]
19
+ * inventory — parsed docs (was `documentationInventory`): [{ id, title, ... }]
20
+ * basePath — mount point of the workshop (drives active-route detection)
21
+ * docHref — builds a doc URL: docHref(id) → doc page; docHref() → docs index.
22
+ * Assumes docs are mounted under `${basePath}/docs/`.
23
+ */
24
+
25
+ const DocsSidebar = ({ inventory = [], docHref, basePath, onNavigate, collapsed, onToggle }) => {
26
+ const location = useLocation()
27
+ const activeDocId = useMemo(() => {
28
+ const prefix = `${basePath}/docs/`
29
+ if (!location.pathname.startsWith(prefix)) return null
30
+ const rest = location.pathname.slice(prefix.length)
31
+ return rest.length > 0 ? rest : null
32
+ }, [location.pathname, basePath])
33
+
34
+ const groupedDocs = useMemo(() => groupDocsByMajor(inventory), [inventory])
35
+
36
+ const [collapsedGroups, setCollapsedGroups] = useState(() => {
37
+ const initialState = {}
38
+ Object.keys(groupedDocs).forEach((major) => {
39
+ initialState[major] = true
40
+ })
41
+ return initialState
42
+ })
43
+
44
+ // Auto-expand the group containing the active doc
45
+ useEffect(() => {
46
+ if (!activeDocId) return
47
+ for (const [major, docs] of Object.entries(groupedDocs)) {
48
+ if (docs.some(d => d.id === activeDocId)) {
49
+ setCollapsedGroups(prev => ({ ...prev, [major]: false }))
50
+ break
51
+ }
52
+ }
53
+ }, [activeDocId, groupedDocs])
54
+
55
+ const toggleGroup = (major) => {
56
+ setCollapsedGroups(prev => ({
57
+ ...prev,
58
+ [major]: !prev[major]
59
+ }))
60
+ }
61
+
62
+ return (
63
+ <div className="space-y-4">
64
+ <div className="shell-sidebar-toggle shell-sidebar-label" style={{ justifyContent: 'space-between', paddingRight: '4px' }}>
65
+ <Link to={docHref()} className="shell-sidebar-label" onClick={(e) => {
66
+ if (collapsed && onToggle) onToggle()
67
+ if (onNavigate) onNavigate(e)
68
+ }}>
69
+ Documentation
70
+ </Link>
71
+ <button
72
+ type="button"
73
+ onClick={onToggle}
74
+ aria-label={collapsed ? 'Expand Documentation' : 'Collapse Documentation'}
75
+ className="flex items-center justify-center"
76
+ style={{ height: '16.5px', marginBottom: '8px' }}
77
+ >
78
+ <Icon
79
+ name="chevron-down"
80
+ size={10}
81
+ className={`stroke-[2.5] transition-transform ${collapsed ? '' : 'rotate-180'}`}
82
+ />
83
+ </button>
84
+ </div>
85
+
86
+ {!collapsed && <div className="space-y-4">
87
+ {Object.entries(groupedDocs)
88
+ .sort(([a], [b]) => a.localeCompare(b))
89
+ .map(([major, docs]) => {
90
+ const isCollapsed = collapsedGroups[major]
91
+ return (
92
+ <div key={major} className="shell-nav-group">
93
+ <button
94
+ type="button"
95
+ className="shell-nav-group-header w-full text-left"
96
+ onClick={() => toggleGroup(major)}
97
+ >
98
+ <span className="flex items-center gap-2">
99
+ <Icon
100
+ name="chevron-right"
101
+ size={12}
102
+ className={`h-3 w-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
103
+ />
104
+ {categoryLabels[major] || 'Other'}
105
+ </span>
106
+ <span className="shell-nav-group-count">({docs.length})</span>
107
+ </button>
108
+ {!isCollapsed && (
109
+ <div className="shell-nav-items">
110
+ {docs.map((d) => {
111
+ const isActive = d.id === activeDocId
112
+ return (
113
+ <Link
114
+ key={d.id}
115
+ to={docHref(d.id)}
116
+ className={`shell-nav-item shell-nav-item-doc ${isActive ? 'active' : ''}`}
117
+ onClick={onNavigate}
118
+ >
119
+ <span className="shell-nav-item-title">{cleanTitle(d.title, d.id)}</span>
120
+ <span className="shell-nav-item-id">{extractDocNumber(d.id)}</span>
121
+ </Link>
122
+ )
123
+ })}
124
+ </div>
125
+ )}
126
+ </div>
127
+ )
128
+ })}
129
+ </div>}
130
+ </div>
131
+ )
132
+ }
133
+
134
+ const WorkshopSidebar = ({
135
+ routes = [],
136
+ inventory = [],
137
+ basePath = '/workshop',
138
+ docHref = (id) => (id ? `${basePath}/docs/${id}` : `${basePath}/docs`),
139
+ onNavigate,
140
+ }) => {
141
+ const location = useLocation()
142
+ const [workshopCollapsed, setWorkshopCollapsed] = useState(false)
143
+ const [docsCollapsed, setDocsCollapsed] = useState(true)
144
+
145
+ // Auto-expand Documentation section when on a docs route
146
+ useEffect(() => {
147
+ if (location.pathname.startsWith(`${basePath}/docs`)) {
148
+ setDocsCollapsed(false)
149
+ }
150
+ }, [location.pathname, basePath])
151
+
152
+ const workshopRoutes = useMemo(
153
+ () => routes.filter(r => r.id !== 'docs'),
154
+ [routes]
155
+ )
156
+
157
+ return (
158
+ <div className="space-y-6">
159
+ <ShellSidebar
160
+ routes={workshopRoutes}
161
+ basePath={basePath}
162
+ onNavigate={onNavigate}
163
+ label="Workshop"
164
+ labelTo={basePath}
165
+ collapsed={workshopCollapsed}
166
+ onToggle={() => setWorkshopCollapsed(p => !p)}
167
+ />
168
+ <DocsSidebar
169
+ inventory={inventory}
170
+ docHref={docHref}
171
+ basePath={basePath}
172
+ onNavigate={onNavigate}
173
+ collapsed={docsCollapsed}
174
+ onToggle={() => setDocsCollapsed(p => !p)}
175
+ />
176
+ </div>
177
+ )
178
+ }
179
+
180
+ export default WorkshopSidebar
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Example compositions — the app-specific glue that wires the KOL shell + docs
3
+ * engine into a product's navigation. These are reference implementations meant
4
+ * to be copied and adapted; they take app data (routes, inventory) and href
5
+ * helpers (basePath, docHref) as props rather than importing any singleton.
6
+ */
7
+ export { default as WorkshopSidebar } from './WorkshopSidebar.jsx'
8
+ export { default as WorkshopDefaultSidebar } from './WorkshopDefaultSidebar.jsx'
@@ -0,0 +1,7 @@
1
+ const join = (...classes) => classes.filter(Boolean).join(' ')
2
+
3
+ const DocsArticle = ({ children, className }) => (
4
+ <article className={join('docs-article', className)}>{children}</article>
5
+ )
6
+
7
+ export default DocsArticle
@@ -0,0 +1,74 @@
1
+ import { Icon, Tag } from '@kolkrabbi/kol-component'
2
+ import { getTagColor } from '../engine'
3
+ import { useTagMode } from '../tags'
4
+
5
+ const FIELD_ICONS = {
6
+ file: 'file',
7
+ title: null,
8
+ version: 'hash-01',
9
+ date: null,
10
+ status: 'check',
11
+ 'content-type': null,
12
+ category: 'folder',
13
+ tags: null,
14
+ modified: null
15
+ }
16
+
17
+ const FIELD_ORDER = ['title', 'category', 'date', 'tags', 'modified']
18
+
19
+ const formatDate = (dateStr) => {
20
+ if (!dateStr) return null
21
+ const d = new Date(dateStr)
22
+ if (isNaN(d)) return dateStr
23
+ return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }).replace(/\//g, '.')
24
+ }
25
+
26
+ const DocsFrontmatter = ({ metadata, docId }) => {
27
+ const { openTagMode } = useTagMode()
28
+ if (!metadata || Object.keys(metadata).length === 0) return null
29
+
30
+ const fields = FIELD_ORDER.filter(key => metadata[key] != null && metadata[key] !== '')
31
+
32
+ if (fields.length === 0) return null
33
+
34
+ return (
35
+ <div className="docs-frontmatter">
36
+ <p className="shell-sidebar-label">Frontmatter</p>
37
+ {fields.map((key) => {
38
+ const value = metadata[key]
39
+ const icon = FIELD_ICONS[key]
40
+
41
+ return (
42
+ <div key={key} className="docs-frontmatter-row">
43
+ <span className="docs-frontmatter-key">
44
+ {icon && <Icon name={icon} size={14} />}
45
+ {key}
46
+ </span>
47
+ <span className="docs-frontmatter-value">
48
+ {key === 'tags' && Array.isArray(value) ? (
49
+ <span className="flex flex-wrap gap-1.5">
50
+ {value.map((tag) => (
51
+ <Tag
52
+ key={tag}
53
+ size="sm"
54
+ color={getTagColor(tag)}
55
+ onClick={() => openTagMode(tag)}
56
+ >
57
+ {tag}
58
+ </Tag>
59
+ ))}
60
+ </span>
61
+ ) : key === 'date' || key === 'modified' ? (
62
+ formatDate(String(value))
63
+ ) : (
64
+ String(value)
65
+ )}
66
+ </span>
67
+ </div>
68
+ )
69
+ })}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ export default DocsFrontmatter
@@ -0,0 +1,19 @@
1
+ const join = (...classes) => classes.filter(Boolean).join(' ')
2
+
3
+ const DocsHeader = ({ title, subtitle, meta = [], className }) => (
4
+ <header className={join('docs-header', className)}>
5
+ {title ? <h1 className="docs-title">{title}</h1> : null}
6
+ {subtitle ? <p className="docs-subtitle">{subtitle}</p> : null}
7
+ {Array.isArray(meta) && meta.length > 0 ? (
8
+ <div className="docs-meta">
9
+ {meta.map(({ label, value }) => (
10
+ <span key={label}>
11
+ <strong>{label}</strong> {value}
12
+ </span>
13
+ ))}
14
+ </div>
15
+ ) : null}
16
+ </header>
17
+ )
18
+
19
+ export default DocsHeader