@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 +38 -0
- package/package.json +46 -0
- package/src/compositions/WorkshopDefaultSidebar.jsx +89 -0
- package/src/compositions/WorkshopSidebar.jsx +180 -0
- package/src/compositions/index.js +8 -0
- package/src/docs/DocsArticle.jsx +7 -0
- package/src/docs/DocsFrontmatter.jsx +74 -0
- package/src/docs/DocsHeader.jsx +19 -0
- package/src/docs/DocumentationReader.jsx +417 -0
- package/src/docs/index.js +14 -0
- package/src/docs/render-tokens.jsx +98 -0
- package/src/engine/__check.mjs +72 -0
- package/src/engine/build-inventory.js +62 -0
- package/src/engine/doc-helpers.js +132 -0
- package/src/engine/frontmatter.js +41 -0
- package/src/engine/index.js +20 -0
- package/src/engine/parse-markdown.js +352 -0
- package/src/engine/search.js +36 -0
- package/src/engine/tags.js +75 -0
- package/src/index.js +15 -0
- package/src/shell/ShellLayout.jsx +197 -0
- package/src/shell/ShellSidebar.jsx +146 -0
- package/src/shell/index.js +11 -0
- package/src/tags/TagGraph.jsx +243 -0
- package/src/tags/TagModeContext.jsx +105 -0
- package/src/tags/TagModeGate.jsx +17 -0
- package/src/tags/TagModeOverlay.jsx +172 -0
- package/src/tags/index.js +11 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router-dom'
|
|
3
|
+
|
|
4
|
+
const TagModeContext = createContext(null)
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Default route helpers. These are the ONLY place the old hardcoded workshop
|
|
8
|
+
* routes survive — pass `docHref` / `tagHref` to the provider to point the tag
|
|
9
|
+
* system at your app's routes. Consumers also inject their docs `inventory`
|
|
10
|
+
* here so the overlay + graph stay decoupled from any Vite-glob singleton.
|
|
11
|
+
*/
|
|
12
|
+
const defaultDocHref = (id) => `/workshop/docs/${id}`
|
|
13
|
+
const defaultTagHref = (tag) => `/workshop/design-system/documentation?tag=${encodeURIComponent(tag)}`
|
|
14
|
+
|
|
15
|
+
export const TagModeProvider = ({
|
|
16
|
+
children,
|
|
17
|
+
inventory = [],
|
|
18
|
+
docHref = defaultDocHref,
|
|
19
|
+
tagHref = defaultTagHref
|
|
20
|
+
}) => {
|
|
21
|
+
const [state, setState] = useState({ isOpen: false, activeTags: [] })
|
|
22
|
+
|
|
23
|
+
const openTagMode = useCallback((tag = null) => {
|
|
24
|
+
setState({ isOpen: true, activeTags: tag ? [tag] : [] })
|
|
25
|
+
}, [])
|
|
26
|
+
|
|
27
|
+
const closeTagMode = useCallback(() => {
|
|
28
|
+
setState({ isOpen: false, activeTags: [] })
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
const toggleTag = useCallback((tag) => {
|
|
32
|
+
setState((prev) => ({
|
|
33
|
+
...prev,
|
|
34
|
+
activeTags: prev.activeTags.includes(tag)
|
|
35
|
+
? prev.activeTags.filter((t) => t !== tag)
|
|
36
|
+
: [...prev.activeTags, tag]
|
|
37
|
+
}))
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
const removeTag = useCallback((tag) => {
|
|
41
|
+
setState((prev) => ({
|
|
42
|
+
...prev,
|
|
43
|
+
activeTags: prev.activeTags.filter((t) => t !== tag)
|
|
44
|
+
}))
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
const clearTags = useCallback(() => {
|
|
48
|
+
setState((prev) => ({ ...prev, activeTags: [] }))
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
// Backward compat: activeTag = first tag (used by TagGraph)
|
|
52
|
+
const activeTag = state.activeTags[0] || null
|
|
53
|
+
|
|
54
|
+
// Backward compat: setActiveTag sets single tag
|
|
55
|
+
const setActiveTag = useCallback((tag) => {
|
|
56
|
+
setState((prev) => ({ ...prev, activeTags: tag ? [tag] : [] }))
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
// Close on any route change
|
|
60
|
+
const location = useLocation()
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (state.isOpen) setState({ isOpen: false, activeTags: [] })
|
|
63
|
+
}, [location.pathname])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!state.isOpen) return
|
|
67
|
+
const handleKeyDown = (e) => {
|
|
68
|
+
if (e.key === 'Escape') {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
closeTagMode()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
74
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
75
|
+
}, [state.isOpen, closeTagMode])
|
|
76
|
+
|
|
77
|
+
const value = useMemo(() => ({
|
|
78
|
+
isOpen: state.isOpen,
|
|
79
|
+
activeTags: state.activeTags,
|
|
80
|
+
activeTag,
|
|
81
|
+
openTagMode,
|
|
82
|
+
closeTagMode,
|
|
83
|
+
toggleTag,
|
|
84
|
+
removeTag,
|
|
85
|
+
clearTags,
|
|
86
|
+
setActiveTag,
|
|
87
|
+
inventory,
|
|
88
|
+
docHref,
|
|
89
|
+
tagHref
|
|
90
|
+
}), [state.isOpen, state.activeTags, activeTag, openTagMode, closeTagMode, toggleTag, removeTag, clearTags, setActiveTag, inventory, docHref, tagHref])
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<TagModeContext.Provider value={value}>
|
|
94
|
+
{children}
|
|
95
|
+
</TagModeContext.Provider>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const useTagMode = () => {
|
|
100
|
+
const ctx = useContext(TagModeContext)
|
|
101
|
+
if (!ctx) throw new Error('useTagMode must be used within TagModeProvider')
|
|
102
|
+
return ctx
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default TagModeContext
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Outlet } from 'react-router-dom'
|
|
2
|
+
import { useTagMode } from './TagModeContext.jsx'
|
|
3
|
+
import TagModeOverlay from './TagModeOverlay.jsx'
|
|
4
|
+
|
|
5
|
+
const TagModeGate = () => {
|
|
6
|
+
const { isOpen } = useTagMode()
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<div style={isOpen ? { display: 'none' } : undefined}>
|
|
10
|
+
<Outlet />
|
|
11
|
+
</div>
|
|
12
|
+
{isOpen && <TagModeOverlay />}
|
|
13
|
+
</>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default TagModeGate
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { Link } from 'react-router-dom'
|
|
3
|
+
import { Input, Tag, Button } from '@kolkrabbi/kol-component'
|
|
4
|
+
import { useTagMode } from './TagModeContext.jsx'
|
|
5
|
+
import TagGraph from './TagGraph.jsx'
|
|
6
|
+
import { extractDocNumber, cleanTitle, getTagColor } from '../engine/index.js'
|
|
7
|
+
import { buildTagCounts } from '../engine/tags.js'
|
|
8
|
+
|
|
9
|
+
const TagModeOverlay = () => {
|
|
10
|
+
const { activeTags, activeTag, toggleTag, removeTag, clearTags, closeTagMode, inventory, docHref, tagHref } = useTagMode()
|
|
11
|
+
const [viewMode, setViewMode] = useState('list')
|
|
12
|
+
const [search, setSearch] = useState('')
|
|
13
|
+
|
|
14
|
+
const allTagsWithCount = useMemo(() => buildTagCounts(inventory), [inventory])
|
|
15
|
+
|
|
16
|
+
const visibleTags = useMemo(() => {
|
|
17
|
+
let tags = allTagsWithCount.filter(({ tag }) => !activeTags.includes(tag))
|
|
18
|
+
if (search.trim()) {
|
|
19
|
+
const q = search.toLowerCase()
|
|
20
|
+
tags = tags.filter(({ tag }) => tag.toLowerCase().includes(q))
|
|
21
|
+
}
|
|
22
|
+
return tags
|
|
23
|
+
}, [allTagsWithCount, search, activeTags])
|
|
24
|
+
|
|
25
|
+
const filteredDocs = useMemo(() => {
|
|
26
|
+
if (activeTags.length === 0) return []
|
|
27
|
+
return inventory.filter((d) => {
|
|
28
|
+
if (!Array.isArray(d.metadata?.tags)) return false
|
|
29
|
+
return activeTags.every((t) => d.metadata.tags.includes(t))
|
|
30
|
+
})
|
|
31
|
+
}, [inventory, activeTags])
|
|
32
|
+
|
|
33
|
+
const hasFilters = activeTags.length > 0
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<article className="docs-article">
|
|
37
|
+
<div className="max-w-[864px] mx-auto">
|
|
38
|
+
<div className="flex items-center justify-start gap-1 mb-3">
|
|
39
|
+
{hasFilters && (
|
|
40
|
+
<Button
|
|
41
|
+
variant="outline"
|
|
42
|
+
quiet
|
|
43
|
+
size="sm"
|
|
44
|
+
iconOnly={viewMode === 'graph' ? 'view-list' : 'polygon'}
|
|
45
|
+
onClick={() => setViewMode(viewMode === 'graph' ? 'list' : 'graph')}
|
|
46
|
+
aria-label={viewMode === 'graph' ? 'List view' : 'Graph view'}
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
<Button
|
|
50
|
+
variant="outline"
|
|
51
|
+
quiet
|
|
52
|
+
size="sm"
|
|
53
|
+
iconOnly="x"
|
|
54
|
+
onClick={closeTagMode}
|
|
55
|
+
aria-label="Close tag mode"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="dash-card flex flex-col gap-8 -mt-4">
|
|
60
|
+
<div className="flex items-center gap-2" style={{ width: '100%', alignSelf: 'stretch' }}>
|
|
61
|
+
<Input
|
|
62
|
+
type="text"
|
|
63
|
+
placeholder="Search tags…"
|
|
64
|
+
value={search}
|
|
65
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
66
|
+
onKeyDown={(e) => {
|
|
67
|
+
if (e.key === 'Enter' && visibleTags.length > 0) {
|
|
68
|
+
toggleTag(visibleTags[0].tag)
|
|
69
|
+
setSearch('')
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
size="md"
|
|
73
|
+
iconLeft="search"
|
|
74
|
+
className="w-full"
|
|
75
|
+
autoFocus
|
|
76
|
+
/>
|
|
77
|
+
{search && (
|
|
78
|
+
<Button
|
|
79
|
+
variant="outline"
|
|
80
|
+
quiet
|
|
81
|
+
size="sm"
|
|
82
|
+
iconOnly="x"
|
|
83
|
+
onClick={() => setSearch('')}
|
|
84
|
+
aria-label="Clear search"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="flex flex-col gap-4 px-10">
|
|
90
|
+
{hasFilters && (
|
|
91
|
+
<>
|
|
92
|
+
<div className="flex items-center justify-between">
|
|
93
|
+
<Button
|
|
94
|
+
variant="outline"
|
|
95
|
+
quiet
|
|
96
|
+
size="sm"
|
|
97
|
+
onClick={clearTags}
|
|
98
|
+
>
|
|
99
|
+
clear filters
|
|
100
|
+
</Button>
|
|
101
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
102
|
+
{activeTags.map((tag) => (
|
|
103
|
+
<Tag
|
|
104
|
+
key={tag}
|
|
105
|
+
variant="solid"
|
|
106
|
+
color={getTagColor(tag)}
|
|
107
|
+
size="md"
|
|
108
|
+
onRemove={() => removeTag(tag)}
|
|
109
|
+
>
|
|
110
|
+
{tag}
|
|
111
|
+
</Tag>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="border-t border-fg-08 my-4" />
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{hasFilters && viewMode === 'graph' ? (
|
|
120
|
+
<div>
|
|
121
|
+
<TagGraph
|
|
122
|
+
docs={filteredDocs}
|
|
123
|
+
allDocs={inventory}
|
|
124
|
+
activeTag={activeTag}
|
|
125
|
+
onTagClick={(tag) => toggleTag(tag)}
|
|
126
|
+
tagHref={tagHref}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<div className="flex flex-col">
|
|
132
|
+
{visibleTags.map(({ tag, count }) => (
|
|
133
|
+
<button
|
|
134
|
+
key={tag}
|
|
135
|
+
type="button"
|
|
136
|
+
className={`tag-list-item${activeTags.includes(tag) ? ' active' : ''}`}
|
|
137
|
+
onClick={() => toggleTag(tag)}
|
|
138
|
+
>
|
|
139
|
+
<span>{tag}</span>
|
|
140
|
+
<span className="tag-list-count">{count}</span>
|
|
141
|
+
</button>
|
|
142
|
+
))}
|
|
143
|
+
{visibleTags.length === 0 && (
|
|
144
|
+
<p className="text-fg-48 kol-mono-xs py-4">No tags matching "{search}"</p>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{hasFilters && filteredDocs.length > 0 && (
|
|
149
|
+
<div className="flex flex-col pt-4 border-t border-fg-08">
|
|
150
|
+
{filteredDocs.map((d) => (
|
|
151
|
+
<Link
|
|
152
|
+
key={d.id}
|
|
153
|
+
to={docHref(d.id)}
|
|
154
|
+
className="tag-list-item"
|
|
155
|
+
onClick={closeTagMode}
|
|
156
|
+
>
|
|
157
|
+
<span>{cleanTitle(d.title, d.id)}</span>
|
|
158
|
+
<span className="tag-list-count">{extractDocNumber(d.id)}</span>
|
|
159
|
+
</Link>
|
|
160
|
+
))}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</article>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default TagModeOverlay
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kolkrabbi/kol-workshop — tag system.
|
|
3
|
+
*
|
|
4
|
+
* Route- and inventory-decoupled tag mode lifted from the monorepo. Wrap the
|
|
5
|
+
* routed tree in <TagModeProvider inventory={...} docHref={...} tagHref={...}>,
|
|
6
|
+
* mount <TagModeGate> as a layout route, and open with useTagMode().openTagMode.
|
|
7
|
+
*/
|
|
8
|
+
export { TagModeProvider, useTagMode } from './TagModeContext.jsx'
|
|
9
|
+
export { default as TagModeGate } from './TagModeGate.jsx'
|
|
10
|
+
export { default as TagModeOverlay } from './TagModeOverlay.jsx'
|
|
11
|
+
export { default as TagGraph } from './TagGraph.jsx'
|