@uniweb/kit 0.9.5 → 0.9.7

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,10 +1,11 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
+ "./xref": "./src/xref/index.js",
8
9
  "./theme-tokens.css": "./src/theme-tokens.css"
9
10
  },
10
11
  "files": [
@@ -38,7 +39,7 @@
38
39
  "fuse.js": "^7.0.0",
39
40
  "shiki": "^3.0.0",
40
41
  "tailwind-merge": "^2.6.0",
41
- "@uniweb/core": "0.7.5"
42
+ "@uniweb/core": "0.7.7"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "react": "^18.0.0 || ^19.0.0",
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Cross-reference renderer.
3
+ *
4
+ * Foundations that want `[#id]` cross-reference rendering import this
5
+ * component from `@uniweb/kit/xref` and register it in their
6
+ * foundation.js's `defaultInsets` map:
7
+ *
8
+ * import { Ref } from '@uniweb/kit/xref'
9
+ * export default {
10
+ * defaultInsets: { Ref },
11
+ * xref: { kinds: { ... } },
12
+ * // ...
13
+ * }
14
+ *
15
+ * Content-reader compiles `[#id]` markdown markers to
16
+ * `inset_ref { component: 'Ref', key: <id> }` regardless of foundation
17
+ * choice; the runtime's inset resolver looks up the component name in
18
+ * the foundation's defaultInsets to find this renderer.
19
+ *
20
+ * Resolution flow at render time:
21
+ * 1. Read `getXrefRegistry(block.website)` — the per-document
22
+ * registry the runtime populated from `{#id}` attributes when the
23
+ * foundation declared `xref:`.
24
+ * 2. Pick the active xref-style preset (foundation default + document
25
+ * override) and the kind metadata for the registered entry.
26
+ * 3. Render via the style's label + counter + locator.
27
+ *
28
+ * Multi-ref clusters (`[#a;#b]`) split on `;` and render same-kind
29
+ * groups using the style's `labelPlural`. Mixed-kind clusters fall
30
+ * back to comma-separated singular rendering with a console warning.
31
+ *
32
+ * Missing ids render `[?<id-with-typo>]` so the failing key is visible
33
+ * in the output — easier to debug than the bare `[?]` we use for
34
+ * missing cite keys.
35
+ */
36
+
37
+ import React from 'react'
38
+ import { getXrefRegistry } from './registry.js'
39
+ import { resolveXrefStyle, getKindMeta } from './styles.js'
40
+
41
+ function splitKeys(raw) {
42
+ return String(raw || '')
43
+ .split(';')
44
+ .map((k) => k.trim().replace(/^#/, ''))
45
+ .filter(Boolean)
46
+ }
47
+
48
+ function formatLocator(params) {
49
+ const { page, locator, label = 'page' } = params || {}
50
+ const value = page || locator
51
+ if (!value) return ''
52
+ const labels = {
53
+ page: 'p.',
54
+ chapter: 'chap.',
55
+ section: '§',
56
+ paragraph: '¶',
57
+ }
58
+ const lab = labels[label] || `${label}.`
59
+ return ` (${lab} ${value})`
60
+ }
61
+
62
+ function renderEntry(entry, kindMeta) {
63
+ const label = kindMeta?.label || ''
64
+ const sep = kindMeta?.sep ?? ' '
65
+ const counter = entry.counterText
66
+ return label ? `${label}${sep}${counter}` : counter
67
+ }
68
+
69
+ function renderGroupSameKind(entries, kindMeta) {
70
+ if (entries.length === 1) {
71
+ return renderEntry(entries[0], kindMeta)
72
+ }
73
+ const label = kindMeta?.labelPlural || kindMeta?.label || ''
74
+ const sep = kindMeta?.sep ?? ' '
75
+ const counters = entries.map((e) => e.counterText)
76
+ let body
77
+ if (counters.length === 2) {
78
+ body = counters.join(' and ')
79
+ } else {
80
+ body = counters.slice(0, -1).join(', ') + ', and ' + counters[counters.length - 1]
81
+ }
82
+ return label ? `${label}${sep}${body}` : body
83
+ }
84
+
85
+ export function Ref({ params, block }) {
86
+ const website = block?.website
87
+ const registry = getXrefRegistry(website)
88
+ const entries = registry?.entries || {}
89
+ const styleName = website?.config?.book?.xrefStyle || 'humanities'
90
+ const style = resolveXrefStyle(styleName, website?.config)
91
+
92
+ const ids = splitKeys(params?.key)
93
+ if (ids.length === 0) {
94
+ return <span className="xref xref--missing" title="No id">[?]</span>
95
+ }
96
+
97
+ const resolved = ids.map((id) => {
98
+ const entry = entries[id]
99
+ return entry ? { id, entry, kindMeta: getKindMeta(style, entry.kind) } : { id, missing: true }
100
+ })
101
+
102
+ if (resolved.length === 1 && resolved[0].missing) {
103
+ return (
104
+ <span className="xref xref--missing" title={`Missing label: ${resolved[0].id}`}>
105
+ [?{resolved[0].id}]
106
+ </span>
107
+ )
108
+ }
109
+
110
+ const allKinds = resolved.filter((r) => !r.missing).map((r) => r.entry.kind)
111
+ const sameKind = allKinds.every((k) => k === allKinds[0])
112
+
113
+ const locator = formatLocator(params)
114
+
115
+ if (!sameKind) {
116
+ if (typeof console !== 'undefined') {
117
+ // eslint-disable-next-line no-console
118
+ console.warn(
119
+ `[xref] mixed-kind cluster (${[...new Set(allKinds)].join(', ')}) — falling back to comma-separated rendering`,
120
+ )
121
+ }
122
+ const parts = resolved.map((r) =>
123
+ r.missing ? `[?${r.id}]` : renderEntry(r.entry, r.kindMeta),
124
+ )
125
+ return <span className="xref">{parts.join(', ')}{locator}</span>
126
+ }
127
+
128
+ const onlyResolved = resolved.filter((r) => !r.missing)
129
+ const text = onlyResolved.length > 0
130
+ ? renderGroupSameKind(
131
+ onlyResolved.map((r) => r.entry),
132
+ onlyResolved[0].kindMeta,
133
+ )
134
+ : ''
135
+
136
+ const missingTail = resolved.filter((r) => r.missing).map((r) => `[?${r.id}]`).join(', ')
137
+ const body = [text, missingTail].filter(Boolean).join(', ')
138
+
139
+ return <span className="xref">{body}{locator}</span>
140
+ }
141
+
142
+ export default Ref
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @uniweb/kit/xref — cross-reference machinery for foundations that
3
+ * support `[#id]` markdown markers.
4
+ *
5
+ * Foundations using cross-references (academic, documentation, long-form)
6
+ * import:
7
+ * - `Ref` to register in `defaultInsets` so content-reader's
8
+ * `inset_ref { component: 'Ref' }` markers resolve.
9
+ * - `buildXrefRegistry` if they want to trigger registry construction
10
+ * manually (the runtime does it automatically when foundation
11
+ * declares `xref:`).
12
+ * - `getXrefRegistry` to read the registry from list sections (e.g.
13
+ * ListOfFigures).
14
+ *
15
+ * Foundations that do NOT use cross-references never import this
16
+ * subpath; kit's tree-shaking strips the entire xref module from their
17
+ * bundle.
18
+ */
19
+
20
+ export { buildXrefRegistry, getXrefRegistry } from './registry.js'
21
+ export {
22
+ XREF_STYLES,
23
+ DEFAULT_XREF_STYLE,
24
+ resolveXrefStyle,
25
+ getKindMeta,
26
+ } from './styles.js'
27
+ export { Ref, default as RefDefault } from './Ref.jsx'
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Cross-reference registry — per-website lookup table from `{#id}` to
3
+ * counter / kind / metadata.
4
+ *
5
+ * The walker visits the parsed document tree (siteContent.pages[].
6
+ * sections[].content) once, finds every block-level element carrying a
7
+ * `{#id}` attribute, infers the element's kind from its node type, and
8
+ * records the entry with a per-kind counter. The registry is consumed
9
+ * by:
10
+ * - The framework's <Ref> component to render `[#id]` cross-references
11
+ * as "Figure 3" / "§3.2" / etc.
12
+ * - List sections (foundation-side: ListOfFigures / ListOfTables /
13
+ * TableOfContents) to enumerate captioned figures/tables/headings.
14
+ * - Press LaTeX adapters' \autoref helpers to filter unresolved ids.
15
+ *
16
+ * Built-in kinds:
17
+ * - heading → 'section' (hierarchical: 1, 1.1, 1.1.1, 2, …)
18
+ * - image → 'figure' (flat arabic counter)
19
+ * - math_display → 'equation' (flat arabic counter)
20
+ * - table → 'table' (flat arabic counter)
21
+ *
22
+ * Foundation extensions: foundations that need additional kinds (e.g.
23
+ * theorem, lemma, definition for an academic foundation) declare them
24
+ * via `foundation.xref.kinds`. The id-collection pass uses each kind's
25
+ * `prefix` field (e.g. `thm` → `theorem`) to classify ids that don't
26
+ * land on a built-in element type.
27
+ *
28
+ * Storage: a WeakMap keyed by Website. The registry is NOT attached as
29
+ * a property on the Website object — keeping the persistent vanilla-JS
30
+ * object graph free of feature-specific properties is a deliberate
31
+ * Uniweb convention. Same pattern as cite-registry.js (book foundation)
32
+ * and Press's DocumentProvider registration store.
33
+ *
34
+ * Lifecycle: built once per website at runtime initialization (see
35
+ * runtime/src/setup.js / ssr-renderer.js's initPrerender — they detect
36
+ * `foundation.xref` and trigger the build). Consumers read it via
37
+ * `getXrefRegistry(website)`. When the website becomes unreachable
38
+ * (page navigation, editor preview swap), the WeakMap entry is GC-
39
+ * eligible automatically.
40
+ *
41
+ * Output shape per entry:
42
+ * {
43
+ * id, // the id itself
44
+ * kind, // 'figure' | 'equation' | 'section' | 'table' | <foundation-declared>
45
+ * counter, // number for flat kinds, dotted string for hierarchical
46
+ * counterText, // displayable counter ('3', '3.2')
47
+ * sourcePath, // page route the id was declared on (for back-refs)
48
+ * caption, // (figures, tables) caption attr, when set
49
+ * text, // (sections) heading's plain text content
50
+ * latex, // (equations) latex source for the equation
51
+ * }
52
+ *
53
+ * `caption`, `text`, and `latex` are populated only for the elements
54
+ * that carry them — undefined on other kinds.
55
+ */
56
+
57
+ const KIND_BY_TYPE = {
58
+ heading: 'section',
59
+ image: 'figure',
60
+ math_display: 'equation',
61
+ table: 'table',
62
+ }
63
+
64
+ /**
65
+ * Per-website registry storage. Keyed by the Website instance (or any
66
+ * object — the WeakMap doesn't enforce a type). Entries are GC-eligible
67
+ * once the website becomes unreachable.
68
+ */
69
+ const REGISTRIES = new WeakMap()
70
+
71
+ /**
72
+ * Walk the website's parsed content tree to build the registry. Stores
73
+ * the result keyed by the website instance.
74
+ *
75
+ * @param {Object} website - The Website instance the registry belongs
76
+ * to. Used as the WeakMap key. Should expose its parsed content
77
+ * either at `website.rawContent` or `website.siteContent` depending
78
+ * on how the runtime exposes it; the walker accepts both.
79
+ * @param {Object} [options]
80
+ * @param {Object} [options.foundationKinds] - Map of kind → metadata
81
+ * the foundation declared via `foundation.xref.kinds`. Drives id-
82
+ * prefix classification for non-built-in kinds (e.g. thm-foo →
83
+ * theorem) and is referenced when an explicit `kind` attribute on a
84
+ * node points at a foundation-declared kind.
85
+ * @param {Function} [options.onWarn] - Per-warning callback. Default
86
+ * logs to console.
87
+ * @returns {{entries: Object}} The built registry.
88
+ */
89
+ export function buildXrefRegistry(website, options = {}) {
90
+ const { foundationKinds = {}, onWarn = (msg) => console.warn(msg) } = options
91
+
92
+ const entries = {}
93
+ const flatCounters = {}
94
+ const sectionStack = []
95
+
96
+ const prefixToKind = {}
97
+ for (const [kind, meta] of Object.entries(foundationKinds)) {
98
+ if (meta?.prefix) prefixToKind[meta.prefix] = kind
99
+ }
100
+
101
+ function nextFlat(kind) {
102
+ flatCounters[kind] = (flatCounters[kind] || 0) + 1
103
+ return flatCounters[kind]
104
+ }
105
+
106
+ let topLevel = null
107
+
108
+ function nextHierarchical(level) {
109
+ if (topLevel == null || level < topLevel) topLevel = level
110
+ const depth = level - topLevel + 1
111
+ while (sectionStack.length >= depth) sectionStack.pop()
112
+ while (sectionStack.length < depth - 1) sectionStack.push(1)
113
+ sectionStack.push((sectionStack[depth - 1] || 0) + 1)
114
+ sectionStack.length = depth
115
+ return sectionStack.slice().join('.')
116
+ }
117
+
118
+ function inferKind(el) {
119
+ // Built-in: element type matches a registered kind.
120
+ const builtin = KIND_BY_TYPE[el.type]
121
+ if (builtin) return builtin
122
+ // Explicit kind attribute on the element.
123
+ if (el.attrs?.kind && (KIND_BY_TYPE[el.attrs.kind] || foundationKinds[el.attrs.kind])) {
124
+ return el.attrs.kind
125
+ }
126
+ // Foundation-declared prefix on the id.
127
+ const id = readId(el)
128
+ if (id && id.includes('-')) {
129
+ const prefix = id.slice(0, id.indexOf('-'))
130
+ if (prefixToKind[prefix]) return prefixToKind[prefix]
131
+ }
132
+ return null
133
+ }
134
+
135
+ // Sequence elements expose `id` differently per element type. Most
136
+ // (heading, image, table) carry it under `attrs`; math_display
137
+ // promotes it to a top-level field (per @uniweb/semantic-parser's
138
+ // sequence builder). Read both.
139
+ function readId(el) {
140
+ return el.attrs?.id ?? el.id ?? null
141
+ }
142
+
143
+ function visit(el, sourcePath) {
144
+ let counter = null
145
+ let counterText = null
146
+ if (el.type === 'heading') {
147
+ const level = Math.max(1, Math.min(6, el.level || el.attrs?.level || 1))
148
+ counterText = nextHierarchical(level)
149
+ counter = counterText
150
+ }
151
+
152
+ const id = readId(el)
153
+ if (!id) return
154
+
155
+ const kind = inferKind(el)
156
+ if (!kind) {
157
+ onWarn(`[xref] {#${id}} on unrecognized element type "${el.type}" — ignored`)
158
+ return
159
+ }
160
+ if (entries[id]) {
161
+ onWarn(`[xref] duplicate id "${id}" — keeping first registration`)
162
+ return
163
+ }
164
+ if (counter == null) {
165
+ counter = nextFlat(kind)
166
+ counterText = String(counter)
167
+ }
168
+ const entry = { id, kind, counter, counterText, sourcePath: sourcePath || '' }
169
+ const caption = el.attrs?.caption
170
+ if ((kind === 'figure' || kind === 'table') && caption) {
171
+ entry.caption = String(caption)
172
+ }
173
+ if (el.type === 'heading' && el.text) {
174
+ entry.text = String(el.text)
175
+ }
176
+ if (el.type === 'math_display' && el.latex) {
177
+ entry.latex = String(el.latex)
178
+ }
179
+ entries[id] = entry
180
+ }
181
+
182
+ // Walk the Website's object graph: pages → bodyBlocks. Each Block's
183
+ // `parsedContent.sequence` is a flat document-order array of
184
+ // semantic elements (heading, paragraph, image, list, blockquote,
185
+ // codeBlock, table, math_display, divider, …). Iterating the
186
+ // sequence is enough — the registry's id-bearing kinds (heading,
187
+ // image, math_display, table) all live at sequence level.
188
+ //
189
+ // Website.pages, Page.bodyBlocks, and Block.parsedContent.sequence
190
+ // are framework invariants — always arrays. No defensive guards.
191
+ for (const page of website.pages) {
192
+ for (const block of page.bodyBlocks) {
193
+ for (const el of block.parsedContent.sequence) {
194
+ visit(el, page.route)
195
+ }
196
+ }
197
+ }
198
+
199
+ const registry = { entries }
200
+ REGISTRIES.set(website, registry)
201
+ return registry
202
+ }
203
+
204
+ /**
205
+ * Read the registry previously built for a website. Returns null if no
206
+ * registry was built (e.g. the foundation didn't declare `xref` so
207
+ * runtime initialization didn't trigger `buildXrefRegistry`).
208
+ *
209
+ * Consumers (Ref renderer, ListOfFigures section, press LaTeX inset
210
+ * formatters) handle the null case gracefully — typically by treating
211
+ * it as an empty entries map, which renders cross-refs as "[?<id>]"
212
+ * placeholders.
213
+ */
214
+ export function getXrefRegistry(website) {
215
+ if (!website || typeof website !== 'object') return null
216
+ return REGISTRIES.get(website) || null
217
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cross-reference style catalog.
3
+ *
4
+ * Cross-references are framework-managed (parallel to citestyle's nine
5
+ * citation styles, but framework-internal because there's no CSL-for-
6
+ * xrefs standard). Foundations pick a preset; documents may override
7
+ * per-kind.
8
+ *
9
+ * Each preset is a map from kind name to:
10
+ * {
11
+ * label, // singular label, e.g. "Figure"
12
+ * labelPlural, // plural label, e.g. "Figures"
13
+ * counter, // 'arabic' | 'hierarchical' | null (no counter)
14
+ * sep, // separator between label and counter ("" | " ")
15
+ * }
16
+ *
17
+ * The catalog ships four presets:
18
+ * - humanities (default — "Figure 3", "§3.2", "Equation 1")
19
+ * - engineering ("Fig. 3", "Sec. 3.2", "Eq. 1")
20
+ * - german ("Abb. 3", "Abschn. 3.2", "Gl. 1")
21
+ * - plain (counters only — "3", "3.2", "1")
22
+ *
23
+ * Foundations override the preset by declaring `xref.kinds` in
24
+ * foundation.js (additive: new kinds and overrides for existing kinds).
25
+ * Documents override per-kind via `book.xref.<kind>:` in document.yml.
26
+ */
27
+
28
+ const HUMANITIES = {
29
+ figure: { label: 'Figure', labelPlural: 'Figures', counter: 'arabic', sep: ' ' },
30
+ equation: { label: 'Equation', labelPlural: 'Equations', counter: 'arabic', sep: ' ' },
31
+ section: { label: '§', labelPlural: '§§', counter: 'hierarchical', sep: '' },
32
+ table: { label: 'Table', labelPlural: 'Tables', counter: 'arabic', sep: ' ' },
33
+ }
34
+
35
+ const ENGINEERING = {
36
+ figure: { label: 'Fig.', labelPlural: 'Figs.', counter: 'arabic', sep: ' ' },
37
+ equation: { label: 'Eq.', labelPlural: 'Eqs.', counter: 'arabic', sep: ' ' },
38
+ section: { label: 'Sec.', labelPlural: 'Secs.', counter: 'hierarchical', sep: ' ' },
39
+ table: { label: 'Tab.', labelPlural: 'Tabs.', counter: 'arabic', sep: ' ' },
40
+ }
41
+
42
+ const GERMAN = {
43
+ figure: { label: 'Abb.', labelPlural: 'Abb.', counter: 'arabic', sep: ' ' },
44
+ equation: { label: 'Gl.', labelPlural: 'Gl.', counter: 'arabic', sep: ' ' },
45
+ section: { label: 'Abschn.', labelPlural: 'Abschn.', counter: 'hierarchical', sep: ' ' },
46
+ table: { label: 'Tab.', labelPlural: 'Tab.', counter: 'arabic', sep: ' ' },
47
+ }
48
+
49
+ const PLAIN = {
50
+ figure: { label: '', labelPlural: '', counter: 'arabic', sep: '' },
51
+ equation: { label: '', labelPlural: '', counter: 'arabic', sep: '' },
52
+ section: { label: '', labelPlural: '', counter: 'hierarchical', sep: '' },
53
+ table: { label: '', labelPlural: '', counter: 'arabic', sep: '' },
54
+ }
55
+
56
+ export const XREF_STYLES = {
57
+ humanities: HUMANITIES,
58
+ engineering: ENGINEERING,
59
+ german: GERMAN,
60
+ plain: PLAIN,
61
+ }
62
+
63
+ export const DEFAULT_XREF_STYLE = 'humanities'
64
+
65
+ /**
66
+ * Resolve the active xref-style for a document. Reads:
67
+ * - presetName (book.xrefStyle: in document config).
68
+ * - `book.xref.<kind>:` per-kind overrides on top of the preset.
69
+ * - Foundation-declared kinds (`foundation.xref.kinds`) extend the
70
+ * preset with whatever the foundation provides.
71
+ *
72
+ * Returns a plain map keyed by kind name; per-kind values are merged
73
+ * objects (preset + foundation extensions + document overrides).
74
+ */
75
+ export function resolveXrefStyle(presetName, config) {
76
+ const preset = XREF_STYLES[presetName] || XREF_STYLES[DEFAULT_XREF_STYLE]
77
+ const merged = { ...preset }
78
+
79
+ // Foundation extensions: kinds declared by the foundation (e.g.
80
+ // theorem, lemma, proof for a math foundation).
81
+ const foundationKinds = globalThis.uniweb?.foundationConfig?.xref?.kinds
82
+ if (foundationKinds && typeof foundationKinds === 'object') {
83
+ for (const [kind, meta] of Object.entries(foundationKinds)) {
84
+ merged[kind] = { ...merged[kind], ...meta }
85
+ }
86
+ }
87
+
88
+ // Document overrides: `book.xref.<kind>:` granular tweaks.
89
+ const docOverrides = config?.book?.xref || config?.xref || null
90
+ if (docOverrides && typeof docOverrides === 'object') {
91
+ for (const [kind, meta] of Object.entries(docOverrides)) {
92
+ if (meta && typeof meta === 'object') {
93
+ merged[kind] = { ...merged[kind], ...meta }
94
+ }
95
+ }
96
+ }
97
+
98
+ return merged
99
+ }
100
+
101
+ export function getKindMeta(style, kind) {
102
+ return style?.[kind] || null
103
+ }