@uniweb/kit 0.9.33 → 0.9.35

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,12 +1,13 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
8
  "./xref": "./src/xref/index.js",
9
- "./theme-tokens.css": "./src/theme-tokens.css"
9
+ "./theme-tokens.css": "./src/theme-tokens.css",
10
+ "./prose-tokens.css": "./src/prose-tokens.css"
10
11
  },
11
12
  "files": [
12
13
  "src",
@@ -39,8 +40,8 @@
39
40
  "fuse.js": "^7.0.0",
40
41
  "shiki": "^3.0.0",
41
42
  "tailwind-merge": "^3.6.0",
42
- "@uniweb/core": "0.7.26",
43
- "@uniweb/scene": "0.1.2"
43
+ "@uniweb/scene": "0.1.2",
44
+ "@uniweb/core": "0.7.27"
44
45
  },
45
46
  "peerDependencies": {
46
47
  "react": "^19.0.0",
@@ -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,218 @@
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
+ * Skips the headings the semantic parser claimed as the section's own
46
+ * title/pretitle/subtitle. In this framework a leading `###` is a pretitle and
47
+ * the `##` after the title is a subtitle — structure, not body headings — so a
48
+ * typed section would otherwise open every contents rail with two entries no
49
+ * reader recognises as sections of the article. An untyped document has none of
50
+ * those fields set, and everything in it counts.
51
+ */
52
+ export function headingsFromContent(page, levels = [2, 3]) {
53
+ const blocks = page?.getBodyBlocks?.() ?? []
54
+ const found = []
55
+
56
+ for (const block of blocks) {
57
+ const nodes = block?.rawContent?.content
58
+ if (!Array.isArray(nodes)) continue
59
+
60
+ const parsed = block.parsedContent || {}
61
+ const claimed = new Set(
62
+ [parsed.title, parsed.pretitle, parsed.subtitle]
63
+ .flat()
64
+ .filter(Boolean)
65
+ .map(value => String(value).trim())
66
+ )
67
+
68
+ for (const node of nodes) {
69
+ if (node?.type !== 'heading') continue
70
+ const level = node.attrs?.level ?? 1
71
+ if (!levels.includes(level)) continue
72
+
73
+ const text = nodeText(node).trim()
74
+ if (!text || claimed.has(text)) continue
75
+
76
+ found.push({ id: headingId(text), text, level })
77
+ }
78
+ }
79
+
80
+ return found
81
+ }
82
+
83
+ /**
84
+ * Fall back to the rendered document, for content this hook cannot see — a
85
+ * foundation rendering its own markup, or anything not built from page blocks.
86
+ */
87
+ function headingsFromDom(root, levels) {
88
+ if (typeof document === 'undefined') return []
89
+
90
+ const scope = document.querySelector(root)
91
+ if (!scope) return []
92
+
93
+ const selector = levels.map(level => `h${level}`).join(', ')
94
+
95
+ return [...scope.querySelectorAll(selector)]
96
+ .map(el => {
97
+ const text = el.textContent?.trim() || ''
98
+ if (!text) return null
99
+ // Adopt the id that is there; stamp the shared one when it is missing, so
100
+ // scrollTo has something to find either way.
101
+ if (!el.id) el.id = headingId(text)
102
+ return { id: el.id, text, level: Number(el.tagName[1]) }
103
+ })
104
+ .filter(Boolean)
105
+ }
106
+
107
+ /**
108
+ * Nest a flat, ordered heading list by level. A heading deeper than the one
109
+ * before it becomes its child; anything at or above the top level starts a new
110
+ * branch. Headings that skip a level still land somewhere sensible.
111
+ */
112
+ function nest(flat) {
113
+ if (!flat.length) return []
114
+
115
+ const topLevel = Math.min(...flat.map(h => h.level))
116
+ const tree = []
117
+ let current = null
118
+
119
+ for (const heading of flat) {
120
+ const node = { ...heading, children: [] }
121
+ if (heading.level === topLevel || !current) {
122
+ tree.push(node)
123
+ current = node
124
+ } else {
125
+ current.children.push(node)
126
+ }
127
+ }
128
+
129
+ return tree
130
+ }
131
+
132
+ /**
133
+ * Height of the fixed site header, so anchors are not scrolled under it.
134
+ */
135
+ function headerOffset(explicit) {
136
+ if (typeof explicit === 'number') return explicit
137
+ if (typeof document === 'undefined') return 0
138
+
139
+ const declared = getComputedStyle(document.documentElement).getPropertyValue('--header-height')
140
+ return (parseInt(declared, 10) || 0) + 16
141
+ }
142
+
143
+ /**
144
+ * @param {Object} [options]
145
+ * @param {number[]} [options.levels=[2,3]] - Heading levels to collect
146
+ * @param {string} [options.root='main'] - Selector to scan, for the DOM fallback
147
+ * @param {number} [options.offset] - Scroll offset in px; defaults to the site
148
+ * header's height read from `--header-height`, plus a little breathing room
149
+ * @returns {{ headings: Array, activeId: string, scrollTo: (id: string) => void }}
150
+ * `headings` is nested — each entry carries `{ id, text, level, children }`
151
+ */
152
+ export function useHeadings({ levels = [2, 3], root = 'main', offset } = {}) {
153
+ const { website } = useWebsite()
154
+ const { route } = useActiveRoute()
155
+ const [domHeadings, setDomHeadings] = useState(null)
156
+ const [activeId, setActiveId] = useState('')
157
+
158
+ // The content path runs during render, so it is available server-side.
159
+ const contentHeadings = useMemo(
160
+ () => headingsFromContent(website?.activePage, levels),
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ [website?.activePage, route, levels.join()]
163
+ )
164
+
165
+ // Only reached when the content path found nothing.
166
+ useEffect(() => {
167
+ setActiveId('')
168
+ if (contentHeadings.length) {
169
+ setDomHeadings(null)
170
+ return
171
+ }
172
+ const id = requestAnimationFrame(() => setDomHeadings(headingsFromDom(root, levels)))
173
+ return () => cancelAnimationFrame(id)
174
+ // eslint-disable-next-line react-hooks/exhaustive-deps
175
+ }, [route, contentHeadings.length, root, levels.join()])
176
+
177
+ const flat = contentHeadings.length ? contentHeadings : domHeadings ?? []
178
+ const headings = useMemo(() => nest(flat), [flat])
179
+
180
+ // Which heading the reader is level with. Scroll position is the one thing
181
+ // here that only the DOM knows.
182
+ useEffect(() => {
183
+ if (!flat.length || typeof window === 'undefined') return
184
+
185
+ const gap = headerOffset(offset)
186
+
187
+ function onScroll() {
188
+ const line = window.scrollY + gap + 4
189
+ let current = ''
190
+ for (const { id } of flat) {
191
+ const el = document.getElementById(id)
192
+ if (el && el.getBoundingClientRect().top + window.scrollY <= line) current = id
193
+ }
194
+ setActiveId(current || flat[0].id)
195
+ }
196
+
197
+ window.addEventListener('scroll', onScroll, { passive: true })
198
+ onScroll()
199
+ return () => window.removeEventListener('scroll', onScroll)
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, [flat.map(h => h.id).join(), offset])
202
+
203
+ const scrollTo = useCallback(
204
+ id => {
205
+ const el = typeof document !== 'undefined' && document.getElementById(id)
206
+ if (!el) return
207
+ window.scrollTo({
208
+ top: el.getBoundingClientRect().top + window.scrollY - headerOffset(offset),
209
+ behavior: 'smooth',
210
+ })
211
+ },
212
+ [offset]
213
+ )
214
+
215
+ return { headings, activeId, scrollTo }
216
+ }
217
+
218
+ 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,
@@ -119,7 +124,6 @@ export {
119
124
  // Styled Components (Tailwind-based)
120
125
  // ============================================================================
121
126
 
122
- export { SidebarLayout } from './styled/SidebarLayout/index.js'
123
127
  export { Section, Render } from './styled/Section/index.js'
124
128
  export { Prose } from './styled/Prose/index.jsx'
125
129
  export { Article } from './styled/Article/index.jsx'
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Tailwind Typography, wired to the theme.
3
+ *
4
+ * Usage — after the plugin, so these win on equal specificity:
5
+ *
6
+ * @plugin "@tailwindcss/typography";
7
+ * @import "@uniweb/kit/prose-tokens.css";
8
+ *
9
+ * `prose` ships its own greys, which means long-form body copy is the one part
10
+ * of a foundation that ignores the site's theme.yml unless someone bridges it
11
+ * by hand. Everyone does bridge it by hand, identically, and the bridge is easy
12
+ * to get subtly wrong — so it lives here.
13
+ *
14
+ * After this import, a prose container answers to the site like everything else
15
+ * and flips with the visitor's scheme on its own. Do not add a `prose-invert`
16
+ * or a `dark:` variant: the tokens have already changed by the time the dark
17
+ * scheme is active, and `prose-invert` would override this with fixed greys.
18
+ *
19
+ * For the same reason, do not add a palette modifier — `prose-gray`,
20
+ * `prose-slate`, `prose-neutral`. Those re-declare every variable below.
21
+ *
22
+ * ── One container ──
23
+ *
24
+ * These variables are inherited, so a `prose` container nested inside another
25
+ * silently resets the lot for its subtree. Two containers is always a bug, and
26
+ * a quiet one: the outer looks right and everything inside it does not. Decide
27
+ * who owns prose — the section that renders the document is usually the better
28
+ * owner, since it then renders correctly under any layout — and make sure the
29
+ * other one only supplies column width and padding.
30
+ */
31
+
32
+ .prose {
33
+ --tw-prose-body: var(--body);
34
+ --tw-prose-headings: var(--heading);
35
+ --tw-prose-lead: var(--subtle);
36
+ --tw-prose-links: var(--link, var(--primary));
37
+ --tw-prose-bold: var(--heading);
38
+ --tw-prose-counters: var(--subtle);
39
+ --tw-prose-bullets: var(--border);
40
+ --tw-prose-hr: var(--border);
41
+ --tw-prose-quotes: var(--subtle);
42
+ --tw-prose-quote-borders: var(--primary);
43
+ --tw-prose-captions: var(--subtle);
44
+ --tw-prose-kbd: var(--heading);
45
+ --tw-prose-code: var(--heading);
46
+ --tw-prose-th-borders: var(--border);
47
+ --tw-prose-td-borders: var(--border);
48
+
49
+ /* Fenced code keeps whatever the highlighter paints — a site retunes that
50
+ through `code:` in theme.yml, not here. */
51
+ --tw-prose-pre-code: var(--code-text, var(--body));
52
+ --tw-prose-pre-bg: var(--code-surface, var(--muted));
53
+ }
54
+
55
+ /* Inline code reads as a chip on whatever surface it sits on, rather than the
56
+ plugin's fixed grey. Scoped to `:not(pre)` so fenced blocks are untouched. */
57
+ .prose :not(pre) > code {
58
+ background-color: color-mix(in oklch, var(--section), var(--heading) 6%);
59
+ border-radius: 0.25rem;
60
+ padding: 0.125rem 0.375rem;
61
+ font-weight: 500;
62
+ font-size: 0.875em;
63
+ }
64
+ .prose :not(pre) > code::before,
65
+ .prose :not(pre) > code::after {
66
+ content: none;
67
+ }
68
+
69
+ /* Anchors land clear of a fixed site header when something scrolls to them —
70
+ a contents rail, or a link with a #fragment. */
71
+ .prose :is(h1, h2, h3, h4) {
72
+ scroll-margin-top: calc(var(--header-height, 4rem) + 1rem);
73
+ }
@@ -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 = generateId(element.text || '')
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 = generateId(text)
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
- * Shiki is lazy-loaded only when code blocks are actually used,
6
- * and CSS variables are injected at runtime from theme.code.
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
- * Map theme.code keys to Shiki CSS variable names
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
- const CSS_VAR_MAP = {
24
- background: '--shiki-background',
25
- foreground: '--shiki-foreground',
26
- keyword: '--shiki-token-keyword',
27
- string: '--shiki-token-string',
28
- number: '--shiki-token-constant',
29
- comment: '--shiki-token-comment',
30
- function: '--shiki-token-function',
31
- variable: '--shiki-token-variable',
32
- operator: '--shiki-token-operator',
33
- punctuation: '--shiki-token-punctuation',
34
- type: '--shiki-token-type',
35
- constant: '--shiki-token-constant',
36
- property: '--shiki-token-property',
37
- tag: '--shiki-token-tag',
38
- attribute: '--shiki-token-attribute',
39
- lineNumber: '--shiki-line-number',
40
- selection: '--shiki-selection',
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
- * Inject CSS variables from theme.code into the document
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 injectCodeThemeCSS(codeTheme) {
100
+ function injectCodeLayoutCSS() {
47
101
  if (cssInjected || typeof document === 'undefined') return
48
102
 
49
- const styleId = 'uniweb-code-theme'
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: 'github-dark',
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
- if (typeof document !== 'undefined' && codeTheme) {
213
- injectCodeThemeCSS(codeTheme)
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 - the code content is already visible and readable.
253
- // When Shiki loads, syntax colors will appear smoothly.
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-gray-900 p-4 text-sm',
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} text-gray-100`}>
310
+ <code className={`language-${lang}`}>
263
311
  {content}
264
312
  </code>
265
313
  </pre>
@@ -7,14 +7,13 @@
7
7
  * For unstyled primitives, use the main '@uniweb/kit' export.
8
8
  *
9
9
  * @example
10
- * import { SidebarLayout, Section, Media } from '@uniweb/kit (styled)'
10
+ * import { Section, Prose, Media } from '@uniweb/kit (styled)'
11
11
  */
12
12
 
13
13
  // ============================================================================
14
14
  // Layout
15
15
  // ============================================================================
16
16
 
17
- export { SidebarLayout } from './SidebarLayout/index.js'
18
17
 
19
18
  // ============================================================================
20
19
  // Content Rendering
@@ -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
+ }
@@ -1,310 +0,0 @@
1
- import React, { useEffect } from 'react'
2
- import { cn } from '../../utils/index.js'
3
- import { useMobileMenu } from '../../hooks/useMobileMenu.js'
4
-
5
- /**
6
- * SidebarLayout Component
7
- *
8
- * A flexible layout with optional left and/or right sidebars.
9
- * On desktop, sidebars appear inline at their configured breakpoints.
10
- * On mobile, the left panel is accessible via a slide-out drawer with FAB toggle.
11
- * The right panel is hidden on mobile (common pattern: nav essential, TOC optional).
12
- *
13
- * The layout is "sandwiched" - header and footer span the full width,
14
- * with the sidebars and content area between them.
15
- *
16
- * @example
17
- * // In foundation's src/foundation.js
18
- * import { SidebarLayout } from '@uniweb/kit'
19
- *
20
- * export default {
21
- * Layout: SidebarLayout,
22
- * }
23
- *
24
- * @example
25
- * // With custom configuration
26
- * import { SidebarLayout } from '@uniweb/kit'
27
- *
28
- * function CustomLayout(props) {
29
- * return (
30
- * <SidebarLayout
31
- * {...props}
32
- * leftBreakpoint="lg"
33
- * rightBreakpoint="xl"
34
- * leftWidth="w-72"
35
- * />
36
- * )
37
- * }
38
- *
39
- * export default { Layout: CustomLayout }
40
- */
41
-
42
- /**
43
- * Hamburger menu icon
44
- */
45
- function MenuIcon({ className }) {
46
- return (
47
- <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
48
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
49
- </svg>
50
- )
51
- }
52
-
53
- /**
54
- * Close (X) icon
55
- */
56
- function CloseIcon({ className }) {
57
- return (
58
- <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
59
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
60
- </svg>
61
- )
62
- }
63
-
64
- /**
65
- * Mobile drawer component (always slides from left)
66
- */
67
- function MobileDrawer({ isOpen, onClose, width, stickyHeader, children }) {
68
- // Prevent body scroll when drawer is open
69
- useEffect(() => {
70
- if (isOpen) {
71
- document.body.style.overflow = 'hidden'
72
- return () => {
73
- document.body.style.overflow = ''
74
- }
75
- }
76
- }, [isOpen])
77
-
78
- // Position below header if sticky, otherwise from top
79
- const topOffset = stickyHeader ? 'top-16' : 'top-0'
80
- const height = stickyHeader ? 'h-[calc(100vh-4rem)]' : 'h-screen'
81
-
82
- return (
83
- <>
84
- {/* Backdrop */}
85
- <div
86
- className={cn(
87
- 'fixed inset-0 bg-black/50 z-40 transition-opacity duration-300',
88
- isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
89
- )}
90
- onClick={onClose}
91
- aria-hidden="true"
92
- />
93
-
94
- {/* Drawer (always from left) */}
95
- <div
96
- className={cn(
97
- 'fixed left-0 bg-white z-50 shadow-xl',
98
- topOffset,
99
- height,
100
- width,
101
- 'transform transition-transform duration-300 ease-in-out',
102
- isOpen ? 'translate-x-0' : '-translate-x-full'
103
- )}
104
- role="dialog"
105
- aria-modal="true"
106
- aria-label="Sidebar navigation"
107
- >
108
- {/* Close button */}
109
- <button
110
- onClick={onClose}
111
- className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-gray-100 transition-colors"
112
- aria-label="Close sidebar"
113
- >
114
- <CloseIcon className="w-5 h-5 text-gray-500" />
115
- </button>
116
-
117
- {/* Drawer content */}
118
- <div className="h-full overflow-y-auto overscroll-contain">
119
- {children}
120
- </div>
121
- </div>
122
- </>
123
- )
124
- }
125
-
126
- /**
127
- * Floating action button for mobile menu (always bottom-left)
128
- */
129
- function FloatingMenuButton({ onClick }) {
130
- return (
131
- <button
132
- onClick={onClick}
133
- className={cn(
134
- 'fixed bottom-4 left-4 z-30',
135
- 'p-3 bg-primary text-white rounded-full shadow-lg',
136
- 'hover:bg-primary/90 active:scale-95 transition-all',
137
- 'focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2'
138
- )}
139
- aria-label="Open navigation menu"
140
- >
141
- <MenuIcon className="w-6 h-6" />
142
- </button>
143
- )
144
- }
145
-
146
- /**
147
- * Get responsive classes for showing/hiding at breakpoint
148
- */
149
- function getBreakpointClasses(breakpoint) {
150
- const showClass = {
151
- sm: 'sm:block',
152
- md: 'md:block',
153
- lg: 'lg:block',
154
- xl: 'xl:block',
155
- }[breakpoint] || 'md:block'
156
-
157
- const hideClass = {
158
- sm: 'sm:hidden',
159
- md: 'md:hidden',
160
- lg: 'lg:hidden',
161
- xl: 'xl:hidden',
162
- }[breakpoint] || 'md:hidden'
163
-
164
- return { showClass, hideClass }
165
- }
166
-
167
- /**
168
- * SidebarLayout main component
169
- *
170
- * @param {Object} props
171
- * @param {React.ReactNode} props.header - Header content (from layout/header.md)
172
- * @param {React.ReactNode} props.body - Main body content (page sections)
173
- * @param {React.ReactNode} props.footer - Footer content (from layout/footer.md)
174
- * @param {React.ReactNode} props.left - Left panel content (from layout/left.md)
175
- * @param {React.ReactNode} props.right - Right panel content (from layout/right.md)
176
- * @param {React.ReactNode} props.leftPanel - Alias for left (backwards compatibility)
177
- * @param {React.ReactNode} props.rightPanel - Alias for right (backwards compatibility)
178
- * @param {string} [props.leftWidth='w-64'] - Tailwind width class for left sidebar
179
- * @param {string} [props.rightWidth='w-64'] - Tailwind width class for right sidebar
180
- * @param {string} [props.drawerWidth='w-72'] - Tailwind width class for mobile drawer
181
- * @param {string} [props.leftBreakpoint='md'] - Breakpoint for showing left sidebar inline
182
- * @param {string} [props.rightBreakpoint='xl'] - Breakpoint for showing right sidebar inline
183
- * @param {boolean} [props.stickyHeader=true] - Whether header sticks to top
184
- * @param {boolean} [props.stickySidebar=true] - Whether sidebars stick below header
185
- * @param {string} [props.maxWidth='max-w-7xl'] - Max width of content area
186
- * @param {string} [props.contentPadding='px-4 py-8 sm:px-6 lg:px-8'] - Padding for main content
187
- * @param {string} [props.className] - Additional classes for the root element
188
- */
189
- export function SidebarLayout({
190
- // Pre-rendered layout areas from runtime
191
- header,
192
- body,
193
- footer,
194
- left,
195
- right,
196
- leftPanel,
197
- rightPanel,
198
- // Configuration
199
- leftWidth = 'w-64',
200
- rightWidth = 'w-64',
201
- drawerWidth = 'w-72',
202
- leftBreakpoint = 'md',
203
- rightBreakpoint = 'xl',
204
- stickyHeader = true,
205
- stickySidebar = true,
206
- maxWidth = 'max-w-7xl',
207
- contentPadding = 'px-4 py-8 sm:px-6 lg:px-8',
208
- className,
209
- }) {
210
- const { isOpen, open, close } = useMobileMenu()
211
-
212
- // Resolve panel content (support both naming conventions)
213
- const leftContent = left || leftPanel
214
- const rightContent = right || rightPanel
215
-
216
- // Get breakpoint classes for each panel
217
- const leftClasses = getBreakpointClasses(leftBreakpoint)
218
- const rightClasses = getBreakpointClasses(rightBreakpoint)
219
-
220
- // Sticky positioning
221
- const headerClasses = stickyHeader
222
- ? 'sticky top-0 z-30'
223
- : ''
224
-
225
- const sidebarClasses = stickySidebar && stickyHeader
226
- ? 'sticky top-16 h-[calc(100vh-4rem)]'
227
- : stickySidebar
228
- ? 'sticky top-0 h-screen'
229
- : ''
230
-
231
- return (
232
- <div className={cn('min-h-screen flex flex-col bg-white', className)}>
233
- {/* Header */}
234
- {header && (
235
- <header className={cn(
236
- 'w-full border-b border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80',
237
- headerClasses
238
- )}>
239
- {header}
240
- </header>
241
- )}
242
-
243
- {/* Mobile Drawer (left panel only) */}
244
- {leftContent && (
245
- <div className={leftClasses.hideClass}>
246
- <MobileDrawer
247
- isOpen={isOpen}
248
- onClose={close}
249
- width={drawerWidth}
250
- stickyHeader={stickyHeader}
251
- >
252
- {leftContent}
253
- </MobileDrawer>
254
- </div>
255
- )}
256
-
257
- {/* Main Content Area */}
258
- <div className={cn('flex-1 w-full mx-auto', maxWidth)}>
259
- <div className="flex">
260
- {/* Left Sidebar (desktop) */}
261
- {leftContent && (
262
- <aside className={cn(
263
- 'hidden flex-shrink-0 overflow-y-auto border-r border-gray-200',
264
- leftClasses.showClass,
265
- leftWidth,
266
- sidebarClasses
267
- )}>
268
- {leftContent}
269
- </aside>
270
- )}
271
-
272
- {/* Main Content */}
273
- <main className="flex-1 min-w-0">
274
- <div className={contentPadding}>
275
- {body}
276
- </div>
277
- </main>
278
-
279
- {/* Right Sidebar (desktop only, hidden on mobile) */}
280
- {rightContent && (
281
- <aside className={cn(
282
- 'hidden flex-shrink-0 overflow-y-auto border-l border-gray-200',
283
- rightClasses.showClass,
284
- rightWidth,
285
- sidebarClasses
286
- )}>
287
- {rightContent}
288
- </aside>
289
- )}
290
- </div>
291
- </div>
292
-
293
- {/* Footer */}
294
- {footer && (
295
- <footer className="w-full border-t border-gray-200">
296
- {footer}
297
- </footer>
298
- )}
299
-
300
- {/* Mobile FAB (only if left panel exists) */}
301
- {leftContent && (
302
- <div className={leftClasses.hideClass}>
303
- <FloatingMenuButton onClick={open} />
304
- </div>
305
- )}
306
- </div>
307
- )
308
- }
309
-
310
- export default SidebarLayout
@@ -1 +0,0 @@
1
- export { SidebarLayout, default } from './SidebarLayout.jsx'