@uniweb/kit 0.9.33 → 0.9.34
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 +2 -2
- package/src/hooks/index.js +1 -0
- package/src/hooks/useHeadings.js +201 -0
- package/src/index.js +5 -0
- package/src/styled/Prose/index.jsx +2 -9
- package/src/styled/Section/Render.jsx +2 -11
- package/src/styled/Section/renderers/Code.jsx +114 -66
- package/src/styled/SidebarLayout/SidebarLayout.jsx +13 -12
- package/src/utils/index.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.34",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"fuse.js": "^7.0.0",
|
|
40
40
|
"shiki": "^3.0.0",
|
|
41
41
|
"tailwind-merge": "^3.6.0",
|
|
42
|
-
"@uniweb/core": "0.7.
|
|
42
|
+
"@uniweb/core": "0.7.27",
|
|
43
43
|
"@uniweb/scene": "0.1.2"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
package/src/hooks/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { useVersion } from './useVersion.js'
|
|
|
9
9
|
export { useScrolled } from './useScrolled.js'
|
|
10
10
|
export { useMobileMenu } from './useMobileMenu.js'
|
|
11
11
|
export { useAccordion } from './useAccordion.js'
|
|
12
|
+
export { useHeadings } from './useHeadings.js'
|
|
12
13
|
export { useGridLayout, getGridClasses } from './useGridLayout.js'
|
|
13
14
|
export { useTheme, getThemeClasses, THEMES, THEME_NAMES } from './useTheme.js'
|
|
14
15
|
export { useInView, useIsInView } from './useInView.js'
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useHeadings Hook
|
|
3
|
+
*
|
|
4
|
+
* The headings of the page being read, plus which one the reader is level with.
|
|
5
|
+
* What a table of contents needs, without any opinion about how it looks.
|
|
6
|
+
*
|
|
7
|
+
* Every documentation shell on this framework has hand-written this, and each
|
|
8
|
+
* one wrote it the only way a foundation can: scan the DOM for `h2, h3` after
|
|
9
|
+
* the body renders. That works, but it costs a frame of timing guesswork and it
|
|
10
|
+
* cannot run during prerender, so the rail is missing from every served page
|
|
11
|
+
* until hydration.
|
|
12
|
+
*
|
|
13
|
+
* The framework can do better because it owns both ends. It renders the
|
|
14
|
+
* headings and stamps their ids (`headingId`), and it holds the page content
|
|
15
|
+
* that produced them — so the list can come from the content, before anything
|
|
16
|
+
* is painted, and be guaranteed to match the anchors. Only the highlight needs
|
|
17
|
+
* the DOM, because only scrolling does.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* function PageContents() {
|
|
21
|
+
* const { headings, activeId, scrollTo } = useHeadings()
|
|
22
|
+
* if (!headings.length) return null
|
|
23
|
+
*
|
|
24
|
+
* return (
|
|
25
|
+
* <nav aria-label="On this page">
|
|
26
|
+
* {headings.map(h => (
|
|
27
|
+
* <button key={h.id} onClick={() => scrollTo(h.id)}
|
|
28
|
+
* className={h.id === activeId ? 'text-primary' : 'text-subtle'}>
|
|
29
|
+
* {h.text}
|
|
30
|
+
* </button>
|
|
31
|
+
* ))}
|
|
32
|
+
* </nav>
|
|
33
|
+
* )
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
38
|
+
import { headingId, nodeText } from '../utils/index.js'
|
|
39
|
+
import { useWebsite } from './useWebsite.js'
|
|
40
|
+
import { useActiveRoute } from './useActiveRoute.js'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pull headings out of the page's own content — available during prerender.
|
|
44
|
+
*/
|
|
45
|
+
function headingsFromContent(page, levels) {
|
|
46
|
+
const blocks = page?.getBodyBlocks?.() ?? []
|
|
47
|
+
const found = []
|
|
48
|
+
|
|
49
|
+
for (const block of blocks) {
|
|
50
|
+
const nodes = block?.rawContent?.content
|
|
51
|
+
if (!Array.isArray(nodes)) continue
|
|
52
|
+
|
|
53
|
+
for (const node of nodes) {
|
|
54
|
+
if (node?.type !== 'heading') continue
|
|
55
|
+
const level = node.attrs?.level ?? 1
|
|
56
|
+
if (!levels.includes(level)) continue
|
|
57
|
+
|
|
58
|
+
const text = nodeText(node).trim()
|
|
59
|
+
if (text) found.push({ id: headingId(text), text, level })
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fall back to the rendered document, for content this hook cannot see — a
|
|
68
|
+
* foundation rendering its own markup, or anything not built from page blocks.
|
|
69
|
+
*/
|
|
70
|
+
function headingsFromDom(root, levels) {
|
|
71
|
+
if (typeof document === 'undefined') return []
|
|
72
|
+
|
|
73
|
+
const scope = document.querySelector(root)
|
|
74
|
+
if (!scope) return []
|
|
75
|
+
|
|
76
|
+
const selector = levels.map(level => `h${level}`).join(', ')
|
|
77
|
+
|
|
78
|
+
return [...scope.querySelectorAll(selector)]
|
|
79
|
+
.map(el => {
|
|
80
|
+
const text = el.textContent?.trim() || ''
|
|
81
|
+
if (!text) return null
|
|
82
|
+
// Adopt the id that is there; stamp the shared one when it is missing, so
|
|
83
|
+
// scrollTo has something to find either way.
|
|
84
|
+
if (!el.id) el.id = headingId(text)
|
|
85
|
+
return { id: el.id, text, level: Number(el.tagName[1]) }
|
|
86
|
+
})
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Nest a flat, ordered heading list by level. A heading deeper than the one
|
|
92
|
+
* before it becomes its child; anything at or above the top level starts a new
|
|
93
|
+
* branch. Headings that skip a level still land somewhere sensible.
|
|
94
|
+
*/
|
|
95
|
+
function nest(flat) {
|
|
96
|
+
if (!flat.length) return []
|
|
97
|
+
|
|
98
|
+
const topLevel = Math.min(...flat.map(h => h.level))
|
|
99
|
+
const tree = []
|
|
100
|
+
let current = null
|
|
101
|
+
|
|
102
|
+
for (const heading of flat) {
|
|
103
|
+
const node = { ...heading, children: [] }
|
|
104
|
+
if (heading.level === topLevel || !current) {
|
|
105
|
+
tree.push(node)
|
|
106
|
+
current = node
|
|
107
|
+
} else {
|
|
108
|
+
current.children.push(node)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return tree
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Height of the fixed site header, so anchors are not scrolled under it.
|
|
117
|
+
*/
|
|
118
|
+
function headerOffset(explicit) {
|
|
119
|
+
if (typeof explicit === 'number') return explicit
|
|
120
|
+
if (typeof document === 'undefined') return 0
|
|
121
|
+
|
|
122
|
+
const declared = getComputedStyle(document.documentElement).getPropertyValue('--header-height')
|
|
123
|
+
return (parseInt(declared, 10) || 0) + 16
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {Object} [options]
|
|
128
|
+
* @param {number[]} [options.levels=[2,3]] - Heading levels to collect
|
|
129
|
+
* @param {string} [options.root='main'] - Selector to scan, for the DOM fallback
|
|
130
|
+
* @param {number} [options.offset] - Scroll offset in px; defaults to the site
|
|
131
|
+
* header's height read from `--header-height`, plus a little breathing room
|
|
132
|
+
* @returns {{ headings: Array, activeId: string, scrollTo: (id: string) => void }}
|
|
133
|
+
* `headings` is nested — each entry carries `{ id, text, level, children }`
|
|
134
|
+
*/
|
|
135
|
+
export function useHeadings({ levels = [2, 3], root = 'main', offset } = {}) {
|
|
136
|
+
const { website } = useWebsite()
|
|
137
|
+
const { route } = useActiveRoute()
|
|
138
|
+
const [domHeadings, setDomHeadings] = useState(null)
|
|
139
|
+
const [activeId, setActiveId] = useState('')
|
|
140
|
+
|
|
141
|
+
// The content path runs during render, so it is available server-side.
|
|
142
|
+
const contentHeadings = useMemo(
|
|
143
|
+
() => headingsFromContent(website?.activePage, levels),
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
[website?.activePage, route, levels.join()]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// Only reached when the content path found nothing.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
setActiveId('')
|
|
151
|
+
if (contentHeadings.length) {
|
|
152
|
+
setDomHeadings(null)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const id = requestAnimationFrame(() => setDomHeadings(headingsFromDom(root, levels)))
|
|
156
|
+
return () => cancelAnimationFrame(id)
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [route, contentHeadings.length, root, levels.join()])
|
|
159
|
+
|
|
160
|
+
const flat = contentHeadings.length ? contentHeadings : domHeadings ?? []
|
|
161
|
+
const headings = useMemo(() => nest(flat), [flat])
|
|
162
|
+
|
|
163
|
+
// Which heading the reader is level with. Scroll position is the one thing
|
|
164
|
+
// here that only the DOM knows.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!flat.length || typeof window === 'undefined') return
|
|
167
|
+
|
|
168
|
+
const gap = headerOffset(offset)
|
|
169
|
+
|
|
170
|
+
function onScroll() {
|
|
171
|
+
const line = window.scrollY + gap + 4
|
|
172
|
+
let current = ''
|
|
173
|
+
for (const { id } of flat) {
|
|
174
|
+
const el = document.getElementById(id)
|
|
175
|
+
if (el && el.getBoundingClientRect().top + window.scrollY <= line) current = id
|
|
176
|
+
}
|
|
177
|
+
setActiveId(current || flat[0].id)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
181
|
+
onScroll()
|
|
182
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
183
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
184
|
+
}, [flat.map(h => h.id).join(), offset])
|
|
185
|
+
|
|
186
|
+
const scrollTo = useCallback(
|
|
187
|
+
id => {
|
|
188
|
+
const el = typeof document !== 'undefined' && document.getElementById(id)
|
|
189
|
+
if (!el) return
|
|
190
|
+
window.scrollTo({
|
|
191
|
+
top: el.getBoundingClientRect().top + window.scrollY - headerOffset(offset),
|
|
192
|
+
behavior: 'smooth',
|
|
193
|
+
})
|
|
194
|
+
},
|
|
195
|
+
[offset]
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return { headings, activeId, scrollTo }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default useHeadings
|
package/src/index.js
CHANGED
|
@@ -62,6 +62,7 @@ export {
|
|
|
62
62
|
useScrolled,
|
|
63
63
|
useMobileMenu,
|
|
64
64
|
useAccordion,
|
|
65
|
+
useHeadings,
|
|
65
66
|
useGridLayout,
|
|
66
67
|
getGridClasses,
|
|
67
68
|
useTheme,
|
|
@@ -99,6 +100,10 @@ export {
|
|
|
99
100
|
parseIconRef,
|
|
100
101
|
// Content utilities
|
|
101
102
|
splitContent,
|
|
103
|
+
// Heading anchors — one generator, shared by the renderers that stamp them
|
|
104
|
+
// and by anything that links to one (see useHeadings).
|
|
105
|
+
headingId,
|
|
106
|
+
nodeText,
|
|
102
107
|
// Href resolution (for foundations rendering their own links or prose HTML)
|
|
103
108
|
applyBasePath,
|
|
104
109
|
resolveRoute,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React from 'react'
|
|
14
|
-
import { cn, getChildBlockRenderer } from '../../utils/index.js'
|
|
14
|
+
import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
|
|
15
15
|
import { SafeHtml } from '../../components/SafeHtml/index.js'
|
|
16
16
|
import { Image } from '../../components/Image/index.js'
|
|
17
17
|
import { Media } from '../../components/Media/index.js'
|
|
@@ -30,13 +30,6 @@ const SIZE_CLASSES = {
|
|
|
30
30
|
'2xl': 'prose-2xl'
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function generateId(text) {
|
|
34
|
-
return text
|
|
35
|
-
.replace(/<[^>]*>/g, '')
|
|
36
|
-
.toLowerCase()
|
|
37
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
38
|
-
.replace(/^-|-$/g, '')
|
|
39
|
-
}
|
|
40
33
|
|
|
41
34
|
const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
|
|
42
35
|
|
|
@@ -92,7 +85,7 @@ function SequenceElement({ element, block }) {
|
|
|
92
85
|
case 'heading': {
|
|
93
86
|
const level = Math.min(element.level || 1, 6)
|
|
94
87
|
const Tag = `h${level}`
|
|
95
|
-
const id =
|
|
88
|
+
const id = headingId(element.text || '')
|
|
96
89
|
return <Tag id={id}><SafeHtml value={element.text} as="span" /></Tag>
|
|
97
90
|
}
|
|
98
91
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react'
|
|
11
|
-
import { cn, getChildBlockRenderer } from '../../utils/index.js'
|
|
11
|
+
import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
|
|
12
12
|
import { SafeHtml } from '../../components/SafeHtml/index.js'
|
|
13
13
|
import { Image } from '../../components/Image/index.js'
|
|
14
14
|
import { Media } from '../../components/Media/index.js'
|
|
@@ -33,15 +33,6 @@ function extractText(node) {
|
|
|
33
33
|
return ''
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Generate ID from heading text
|
|
38
|
-
*/
|
|
39
|
-
function generateId(text) {
|
|
40
|
-
return text
|
|
41
|
-
.toLowerCase()
|
|
42
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
-
.replace(/^-|-$/g, '')
|
|
44
|
-
}
|
|
45
36
|
|
|
46
37
|
/**
|
|
47
38
|
* Render a list (ordered or unordered)
|
|
@@ -81,7 +72,7 @@ function RenderNode({ node, block, ...props }) {
|
|
|
81
72
|
case 'heading': {
|
|
82
73
|
const level = attrs?.level || 1
|
|
83
74
|
const text = extractText(node)
|
|
84
|
-
const id =
|
|
75
|
+
const id = headingId(text)
|
|
85
76
|
const Tag = `h${Math.min(level, 6)}`
|
|
86
77
|
|
|
87
78
|
return (
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Code Block Renderer
|
|
3
3
|
*
|
|
4
|
-
* Renders syntax-highlighted code blocks using Shiki
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Renders syntax-highlighted code blocks using Shiki, lazy-loaded only when a
|
|
5
|
+
* page actually has one. A site's `theme.yml` `code:` block becomes a Shiki
|
|
6
|
+
* theme layered over the default, so the colours a site declares are the
|
|
7
|
+
* colours Shiki writes.
|
|
7
8
|
*
|
|
8
9
|
* @module @uniweb/kit/Section/renderers/Code
|
|
9
10
|
*/
|
|
@@ -16,37 +17,90 @@ import { getUniweb } from '@uniweb/core'
|
|
|
16
17
|
let cssInjected = false
|
|
17
18
|
let shikiInstance = null
|
|
18
19
|
let shikiLoadPromise = null
|
|
20
|
+
let siteThemeLoaded = false
|
|
21
|
+
|
|
22
|
+
// What a site gets when it declares no `code:` block of its own, and the base
|
|
23
|
+
// every declaration layers over.
|
|
24
|
+
const DEFAULT_THEME = 'github-dark'
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
|
-
*
|
|
27
|
+
* Where each documented `theme.code` key lands in TextMate scope terms.
|
|
28
|
+
*
|
|
29
|
+
* Shiki colours by scope, so a declaration only reaches the output by becoming
|
|
30
|
+
* a scope rule. This used to map the same keys onto `--shiki-*` CSS variables,
|
|
31
|
+
* which could never take effect: Shiki writes its theme as an inline style on
|
|
32
|
+
* the <pre> and on every token span, and an inline style outranks any
|
|
33
|
+
* stylesheet. The `code:` block was documented, parsed, and inert.
|
|
34
|
+
*
|
|
35
|
+
* `lineNumber` and `selection` are deliberately absent. They are editor chrome,
|
|
36
|
+
* not token scopes, and Shiki's HTML has neither.
|
|
37
|
+
*/
|
|
38
|
+
const SCOPE_MAP = {
|
|
39
|
+
comment: ['comment', 'punctuation.definition.comment'],
|
|
40
|
+
string: ['string', 'string.quoted', 'constant.other.symbol'],
|
|
41
|
+
keyword: ['keyword', 'storage', 'storage.type', 'keyword.control'],
|
|
42
|
+
operator: ['keyword.operator'],
|
|
43
|
+
function: ['entity.name.function', 'support.function', 'meta.function-call'],
|
|
44
|
+
variable: ['variable', 'variable.other.readwrite'],
|
|
45
|
+
number: ['constant.numeric'],
|
|
46
|
+
constant: ['constant.language', 'constant.character', 'support.constant'],
|
|
47
|
+
type: ['entity.name.type', 'entity.name.class', 'support.type', 'support.class'],
|
|
48
|
+
property: ['variable.other.property', 'support.type.property-name', 'meta.object-literal.key'],
|
|
49
|
+
tag: ['entity.name.tag'],
|
|
50
|
+
attribute: ['entity.other.attribute-name'],
|
|
51
|
+
punctuation: ['punctuation'],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SITE_CODE_THEME = 'uniweb-site-code'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a Shiki theme from a site's `theme.yml` `code:` block, layered over a
|
|
58
|
+
* base theme.
|
|
59
|
+
*
|
|
60
|
+
* Layering rather than replacing is the point. Most sites declare `background:`
|
|
61
|
+
* alone — "the same highlighting, on my surface" — and building a theme from
|
|
62
|
+
* only the declared keys would strip every syntax colour to answer that. So the
|
|
63
|
+
* base supplies everything, and each declared key overrides its own scopes.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} base - A resolved Shiki theme to layer over
|
|
66
|
+
* @param {Object} code - The site's `theme.code` declaration
|
|
67
|
+
* @returns {Object} A Shiki theme registration
|
|
22
68
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
69
|
+
export function buildCodeTheme(base, code) {
|
|
70
|
+
const theme = {
|
|
71
|
+
...base,
|
|
72
|
+
name: SITE_CODE_THEME,
|
|
73
|
+
colors: { ...base?.colors },
|
|
74
|
+
// Declared scopes go last so they win over the base's own rules.
|
|
75
|
+
settings: [...(base?.settings ?? [])],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (code?.background) {
|
|
79
|
+
theme.bg = code.background
|
|
80
|
+
theme.colors['editor.background'] = code.background
|
|
81
|
+
}
|
|
82
|
+
if (code?.foreground) {
|
|
83
|
+
theme.fg = code.foreground
|
|
84
|
+
theme.colors['editor.foreground'] = code.foreground
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const [key, scope] of Object.entries(SCOPE_MAP)) {
|
|
88
|
+
if (code?.[key]) theme.settings.push({ scope, settings: { foreground: code[key] } })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return theme
|
|
41
92
|
}
|
|
42
93
|
|
|
43
94
|
/**
|
|
44
|
-
*
|
|
95
|
+
* Layout for the highlighted block. Colour is not set here — Shiki writes the
|
|
96
|
+
* theme's own inline, and the theme is now the site's (see buildCodeTheme).
|
|
97
|
+
* What is left is spacing and the code face, so a listing is legible before a
|
|
98
|
+
* foundation styles it, without kit deciding what colour anything is.
|
|
45
99
|
*/
|
|
46
|
-
function
|
|
100
|
+
function injectCodeLayoutCSS() {
|
|
47
101
|
if (cssInjected || typeof document === 'undefined') return
|
|
48
102
|
|
|
49
|
-
const styleId = 'uniweb-code-
|
|
103
|
+
const styleId = 'uniweb-code-layout'
|
|
50
104
|
|
|
51
105
|
// Check if already injected (e.g., by another component instance)
|
|
52
106
|
if (document.getElementById(styleId)) {
|
|
@@ -54,38 +108,15 @@ function injectCodeThemeCSS(codeTheme) {
|
|
|
54
108
|
return
|
|
55
109
|
}
|
|
56
110
|
|
|
57
|
-
// Build CSS variables
|
|
58
|
-
const cssVars = []
|
|
59
|
-
for (const [key, value] of Object.entries(codeTheme || {})) {
|
|
60
|
-
const varName = CSS_VAR_MAP[key]
|
|
61
|
-
if (varName && value) {
|
|
62
|
-
cssVars.push(`${varName}: ${value};`)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Create and inject style element
|
|
67
111
|
const style = document.createElement('style')
|
|
68
112
|
style.id = styleId
|
|
69
113
|
style.textContent = `
|
|
70
|
-
:root {
|
|
71
|
-
${cssVars.join('\n ')}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/* Code block base styles */
|
|
75
114
|
.shiki {
|
|
76
|
-
background-color: var(--shiki-background, #1e1e2e);
|
|
77
|
-
color: var(--shiki-foreground, #cdd6f4);
|
|
78
115
|
padding: 1rem;
|
|
79
116
|
border-radius: 0.5rem;
|
|
80
117
|
overflow-x: auto;
|
|
81
118
|
}
|
|
82
119
|
|
|
83
|
-
/* Ensure proper token colors */
|
|
84
|
-
.shiki span {
|
|
85
|
-
color: var(--shiki-token-foreground, inherit);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/* Code element inside shiki */
|
|
89
120
|
.shiki code {
|
|
90
121
|
display: block;
|
|
91
122
|
font-family: var(--font-code, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace);
|
|
@@ -138,13 +169,33 @@ async function loadShiki() {
|
|
|
138
169
|
return shikiLoadPromise
|
|
139
170
|
}
|
|
140
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Register the site's `code:` declaration as a theme, once, and answer with the
|
|
174
|
+
* theme name to highlight against. Sites that declare nothing get the default.
|
|
175
|
+
*/
|
|
176
|
+
async function resolveThemeName(highlighter, codeTheme) {
|
|
177
|
+
if (!codeTheme || Object.keys(codeTheme).length === 0) return DEFAULT_THEME
|
|
178
|
+
if (siteThemeLoaded) return SITE_CODE_THEME
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await highlighter.loadTheme(buildCodeTheme(highlighter.getTheme(DEFAULT_THEME), codeTheme))
|
|
182
|
+
siteThemeLoaded = true
|
|
183
|
+
return SITE_CODE_THEME
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.warn('[Code] Could not apply theme.code, using the default:', error)
|
|
186
|
+
return DEFAULT_THEME
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
141
190
|
/**
|
|
142
191
|
* Highlight code using Shiki
|
|
143
192
|
*/
|
|
144
|
-
async function highlightCode(code, language, highlighter) {
|
|
193
|
+
async function highlightCode(code, language, highlighter, codeTheme) {
|
|
145
194
|
if (!highlighter) return null
|
|
146
195
|
|
|
147
196
|
try {
|
|
197
|
+
const theme = await resolveThemeName(highlighter, codeTheme)
|
|
198
|
+
|
|
148
199
|
// Load language if not already loaded
|
|
149
200
|
const loadedLangs = highlighter.getLoadedLanguages()
|
|
150
201
|
const lang = language?.toLowerCase() || 'plaintext'
|
|
@@ -154,16 +205,13 @@ async function highlightCode(code, language, highlighter) {
|
|
|
154
205
|
await highlighter.loadLanguage(lang)
|
|
155
206
|
} catch {
|
|
156
207
|
// Language not available, fall back to plaintext
|
|
157
|
-
return highlighter.codeToHtml(code, {
|
|
158
|
-
lang: 'plaintext',
|
|
159
|
-
theme: 'github-dark',
|
|
160
|
-
})
|
|
208
|
+
return highlighter.codeToHtml(code, { lang: 'plaintext', theme })
|
|
161
209
|
}
|
|
162
210
|
}
|
|
163
211
|
|
|
164
212
|
return highlighter.codeToHtml(code, {
|
|
165
213
|
lang: lang === 'plaintext' ? 'text' : lang,
|
|
166
|
-
theme
|
|
214
|
+
theme,
|
|
167
215
|
})
|
|
168
216
|
} catch (error) {
|
|
169
217
|
console.warn('[Code] Highlighting failed:', error)
|
|
@@ -207,12 +255,11 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
207
255
|
return aliases[l] || l
|
|
208
256
|
}, [language])
|
|
209
257
|
|
|
210
|
-
// Inject CSS on first render (if in browser)
|
|
258
|
+
// Inject layout CSS on first render (if in browser). Unlike the colours, this
|
|
259
|
+
// is wanted whether or not the site declared a `code:` block.
|
|
211
260
|
useEffect(() => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
}, [codeTheme])
|
|
261
|
+
injectCodeLayoutCSS()
|
|
262
|
+
}, [])
|
|
216
263
|
|
|
217
264
|
// Load Shiki and highlight code
|
|
218
265
|
useEffect(() => {
|
|
@@ -223,7 +270,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
223
270
|
if (cancelled) return
|
|
224
271
|
|
|
225
272
|
if (highlighter && content) {
|
|
226
|
-
const html = await highlightCode(content, lang, highlighter)
|
|
273
|
+
const html = await highlightCode(content, lang, highlighter, codeTheme)
|
|
227
274
|
if (!cancelled) {
|
|
228
275
|
setHighlightedHtml(html)
|
|
229
276
|
}
|
|
@@ -235,7 +282,7 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
235
282
|
return () => {
|
|
236
283
|
cancelled = true
|
|
237
284
|
}
|
|
238
|
-
}, [content, lang])
|
|
285
|
+
}, [content, lang, codeTheme])
|
|
239
286
|
|
|
240
287
|
// Render highlighted code or fallback
|
|
241
288
|
if (highlightedHtml) {
|
|
@@ -248,18 +295,19 @@ export function Code({ content, language = 'plaintext', className, ...props }) {
|
|
|
248
295
|
)
|
|
249
296
|
}
|
|
250
297
|
|
|
251
|
-
// Fallback: plain code block (shown before Shiki loads or if it fails)
|
|
252
|
-
// No loading indicator
|
|
253
|
-
//
|
|
298
|
+
// Fallback: plain code block (shown before Shiki loads or if it fails).
|
|
299
|
+
// No loading indicator — the code is already readable, and the syntax colours
|
|
300
|
+
// arrive when Shiki does. Semantic tokens, so the wait looks like the site
|
|
301
|
+
// rather than like a fixed grey no theme asked for.
|
|
254
302
|
return (
|
|
255
303
|
<pre
|
|
256
304
|
className={cn(
|
|
257
|
-
'overflow-x-auto rounded-lg bg-
|
|
305
|
+
'overflow-x-auto rounded-lg bg-muted p-4 text-sm text-body',
|
|
258
306
|
className
|
|
259
307
|
)}
|
|
260
308
|
{...props}
|
|
261
309
|
>
|
|
262
|
-
<code className={`language-${lang}
|
|
310
|
+
<code className={`language-${lang}`}>
|
|
263
311
|
{content}
|
|
264
312
|
</code>
|
|
265
313
|
</pre>
|
|
@@ -76,14 +76,15 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
|
|
|
76
76
|
}, [isOpen])
|
|
77
77
|
|
|
78
78
|
// Position below header if sticky, otherwise from top
|
|
79
|
-
const topOffset = stickyHeader ? 'top-
|
|
80
|
-
const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
|
|
79
|
+
const topOffset = stickyHeader ? 'top-[var(--header-height,4rem)]' : 'top-0'
|
|
80
|
+
const height = stickyHeader ? 'h-[calc(100vh-var(--header-height,4rem))]' : 'h-screen'
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
83
|
<>
|
|
84
84
|
{/* Backdrop */}
|
|
85
85
|
<div
|
|
86
86
|
className={cn(
|
|
87
|
+
// kit-palette-ok: a modal scrim is the same black in either scheme
|
|
87
88
|
'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
|
|
88
89
|
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
89
90
|
)}
|
|
@@ -94,7 +95,7 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
|
|
|
94
95
|
{/* Drawer (always from left) */}
|
|
95
96
|
<div
|
|
96
97
|
className={cn(
|
|
97
|
-
'fixed left-0 bg-
|
|
98
|
+
'fixed left-0 bg-section z-50 shadow-xl',
|
|
98
99
|
topOffset,
|
|
99
100
|
height,
|
|
100
101
|
width,
|
|
@@ -108,10 +109,10 @@ function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
|
|
|
108
109
|
{/* Close button */}
|
|
109
110
|
<button
|
|
110
111
|
onClick={onClose}
|
|
111
|
-
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-
|
|
112
|
+
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-muted transition-colors"
|
|
112
113
|
aria-label="Close sidebar"
|
|
113
114
|
>
|
|
114
|
-
<CloseIcon className="w-5 h-5 text-
|
|
115
|
+
<CloseIcon className="w-5 h-5 text-subtle" />
|
|
115
116
|
</button>
|
|
116
117
|
|
|
117
118
|
{/* Drawer content */}
|
|
@@ -132,7 +133,7 @@ function FloatingMenuButton({ onClick }) {
|
|
|
132
133
|
onClick={onClick}
|
|
133
134
|
className={cn(
|
|
134
135
|
'fixed bottom-4 left-4 z-30',
|
|
135
|
-
'p-3 bg-primary text-
|
|
136
|
+
'p-3 bg-primary text-primary-foreground rounded-full shadow-lg',
|
|
136
137
|
'hover:bg-primary/90 active:scale-95 transition-all',
|
|
137
138
|
'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
|
|
138
139
|
)}
|
|
@@ -223,17 +224,17 @@ export function SidebarLayout({
|
|
|
223
224
|
: ''
|
|
224
225
|
|
|
225
226
|
const sidebarClasses = stickySidebar && stickyHeader
|
|
226
|
-
? 'sticky top-
|
|
227
|
+
? 'sticky top-[var(--header-height,4rem)] h-[calc(100vh-var(--header-height,4rem))]'
|
|
227
228
|
: stickySidebar
|
|
228
229
|
? 'sticky top-0 h-screen'
|
|
229
230
|
: ''
|
|
230
231
|
|
|
231
232
|
return (
|
|
232
|
-
<div className={cn('min-h-screen flex flex-col bg-
|
|
233
|
+
<div className={cn('min-h-screen flex flex-col bg-section', className)}>
|
|
233
234
|
{/* Header */}
|
|
234
235
|
{header && (
|
|
235
236
|
<header className={cn(
|
|
236
|
-
'w-full border-b border-
|
|
237
|
+
'w-full border-b border-border bg-section/95 backdrop-blur supports-[backdrop-filter]:bg-section/80',
|
|
237
238
|
headerClasses
|
|
238
239
|
)}>
|
|
239
240
|
{header}
|
|
@@ -260,7 +261,7 @@ export function SidebarLayout({
|
|
|
260
261
|
{/* Left Sidebar (desktop) */}
|
|
261
262
|
{leftContent && (
|
|
262
263
|
<aside className={cn(
|
|
263
|
-
'hidden flex-shrink-0 overflow-y-auto border-r border-
|
|
264
|
+
'hidden flex-shrink-0 overflow-y-auto border-r border-border',
|
|
264
265
|
leftClasses.showClass,
|
|
265
266
|
leftWidth,
|
|
266
267
|
sidebarClasses
|
|
@@ -279,7 +280,7 @@ export function SidebarLayout({
|
|
|
279
280
|
{/* Right Sidebar (desktop only, hidden on mobile) */}
|
|
280
281
|
{rightContent && (
|
|
281
282
|
<aside className={cn(
|
|
282
|
-
'hidden flex-shrink-0 overflow-y-auto border-l border-
|
|
283
|
+
'hidden flex-shrink-0 overflow-y-auto border-l border-border',
|
|
283
284
|
rightClasses.showClass,
|
|
284
285
|
rightWidth,
|
|
285
286
|
sidebarClasses
|
|
@@ -292,7 +293,7 @@ export function SidebarLayout({
|
|
|
292
293
|
|
|
293
294
|
{/* Footer */}
|
|
294
295
|
{footer && (
|
|
295
|
-
<footer className="w-full border-t border-
|
|
296
|
+
<footer className="w-full border-t border-border">
|
|
296
297
|
{footer}
|
|
297
298
|
</footer>
|
|
298
299
|
)}
|
package/src/utils/index.js
CHANGED
|
@@ -248,3 +248,37 @@ export function detectMediaType(url) {
|
|
|
248
248
|
// ─────────────────────────────────────────────────────────────────
|
|
249
249
|
|
|
250
250
|
export { submitForm, derivePreviewFromFormData } from './submitForm.js'
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The text of a ProseMirror node, flattened.
|
|
254
|
+
*
|
|
255
|
+
* @param {Object|string} node - A ProseMirror node
|
|
256
|
+
* @returns {string}
|
|
257
|
+
*/
|
|
258
|
+
export function nodeText(node) {
|
|
259
|
+
if (!node) return ''
|
|
260
|
+
if (typeof node === 'string') return node
|
|
261
|
+
if (node.text) return node.text
|
|
262
|
+
if (node.content) return node.content.map(nodeText).join('')
|
|
263
|
+
return ''
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The anchor id for a heading.
|
|
268
|
+
*
|
|
269
|
+
* One generator, used by every renderer that stamps a heading and by anything
|
|
270
|
+
* that links to one. That agreement is the whole point: a table of contents
|
|
271
|
+
* scrolls to an id some other module produced, so a second implementation is a
|
|
272
|
+
* drift waiting to happen — and there were two, differing in whether they
|
|
273
|
+
* stripped markup first, which decided whether a heading containing a link got
|
|
274
|
+
* a usable anchor.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} text - Heading text, plain or containing markup
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
export function headingId(text) {
|
|
280
|
+
return stripTags(String(text ?? ''))
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
283
|
+
.replace(/^-|-$/g, '')
|
|
284
|
+
}
|