@uniweb/kit 0.9.45 → 0.10.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Render — the content engine.
3
+ *
4
+ * Walks a parsed content SEQUENCE and emits React. One node table, used by
5
+ * everything in kit that renders authored content: `<Prose>` adds typography on
6
+ * top, `<Article>` adds semantics on top of that, and a foundation can call
7
+ * this directly.
8
+ *
9
+ * ── Why there is one table now ──
10
+ *
11
+ * There were two. This component predated the semantic parser's `sequence` and
12
+ * walked raw ProseMirror by hand; `<Prose>` was written later against the
13
+ * parsed shape. Same job, two implementations, and the older one rotted because
14
+ * nothing exercised it. Measured 2026-07-30, before this rewrite:
15
+ *
16
+ * - it dropped every inline mark. Its `paragraph` case called `extractText`,
17
+ * a helper written to extract PLAIN text, which concatenates `node.text`
18
+ * and discards `marks`. `Hello **world**` rendered as `Hello world`, and
19
+ * every link in an article vanished.
20
+ * - it dropped block math entirely — no `math_display` case, so a `$$…$$`
21
+ * fell to `default:`, which recurses `content`, which math has none of.
22
+ * - `<Prose>` meanwhile dropped every table, and that one was not even its
23
+ * fault: the sequence did not carry tables at all until the same day.
24
+ *
25
+ * All three were invisible because each renderer's own tests passed. Going
26
+ * through the parser fixes the marks by construction — inline content arrives
27
+ * already rendered to HTML with marks applied — and one table means math and
28
+ * tables cannot be present in one lane and missing from the other.
29
+ *
30
+ * ── What it does NOT do ──
31
+ *
32
+ * Decorate. The defaults resolve references and supply behaviour — an icon's
33
+ * library, a link's base path, a code block's highlighting, a video's playback
34
+ * attributes — and otherwise emit semantic HTML with no classes. What a
35
+ * blockquote LOOKS like is a foundation's decision and a site's `theme.yml`,
36
+ * the same rule that keeps fixed palettes out of kit (framework CLAUDE.md
37
+ * gotcha #8). The previous version carried 23 hardcoded utility strings,
38
+ * including `italic text-subtle` on blockquote — kit deciding a foundation's
39
+ * design, and unreachable without forking the walker.
40
+ *
41
+ * `components` is the seam instead: pass a component for any element type and
42
+ * it replaces the default, receiving `{ element, block, children }`. That is
43
+ * how a foundation renders a concept block as its own accordion, or a
44
+ * blockquote as its own pull-quote, without kit knowing either exists.
45
+ *
46
+ * @module @uniweb/kit/styled/Render
47
+ */
48
+
49
+ import React from 'react'
50
+ import { parseContent } from '@uniweb/semantic-parser'
51
+ import { getChildBlockRenderer, headingId } from '../../utils/index.js'
52
+ import { SafeHtml } from '../../components/SafeHtml/index.js'
53
+ import { Image } from '../../components/Image/index.js'
54
+ import { Media } from '../../components/Media/index.js'
55
+ import { Icon } from '../../components/Icon/index.js'
56
+ import { Link } from '../../components/Link/index.js'
57
+ import { Code } from '../Section/renderers/Code.jsx'
58
+ import { Divider } from '../Section/renderers/Divider.jsx'
59
+
60
+ const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
61
+
62
+ /**
63
+ * Element types this engine knows about and deliberately renders NOTHING for.
64
+ *
65
+ * Listed rather than left to fall through `default:`, so a drop is a decision
66
+ * someone wrote down. `tests/renderer-coverage.test.js` checks this set plus
67
+ * the rendered set against the vocabulary the parser can actually emit, which
68
+ * is what makes a NEW element type fail a test instead of vanishing.
69
+ */
70
+ export const NOT_RENDERED = {
71
+ dataBlock:
72
+ 'structured data, not prose — a component reads it from content.data[tag]',
73
+ inset_ref:
74
+ 'a BUILD-TIME intermediate. The reader emits it for `![](@Component)`; the ' +
75
+ 'build\'s content-collector extracts it into the section\'s insets and leaves ' +
76
+ 'an `inset_placeholder`, which is what the `inset` case resolves. Its attrs ' +
77
+ 'carry a component NAME and no refId, so there is nothing here to look up — ' +
78
+ 'and kit answering for the name is the shadowing the inset_block case ' +
79
+ 'refuses. Consequence worth knowing: a document rendered straight from ' +
80
+ 'markdown without that build step (a collection record body, say) loses its ' +
81
+ 'inline component references.',
82
+ form: 'an editor node; its data reaches components as content.data[schemaId]',
83
+ 'card-group': 'an editor node, deprecated — maps to content.data[cardType]',
84
+ 'document-group': 'an editor node — its documents reach content.links',
85
+ }
86
+
87
+ /**
88
+ * Normalize whatever a caller passed into a sequence.
89
+ *
90
+ * Accepts a parsed content object (`content.sequence`), a Block (its content is
91
+ * already parsed), a ProseMirror doc, or an array of ProseMirror nodes. A raw
92
+ * document is run through the parser rather than walked directly — that is the
93
+ * whole point of having one table, and it is what makes inline marks work.
94
+ */
95
+ function toSequence(content, block) {
96
+ if (Array.isArray(content?.sequence)) return content.sequence
97
+
98
+ if (content?.type === 'doc') return parseContent(content).sequence
99
+ if (Array.isArray(content)) return parseContent({ type: 'doc', content }).sequence
100
+
101
+ if (block) {
102
+ if (Array.isArray(block.parsedContent?.sequence)) return block.parsedContent.sequence
103
+ const raw = block.rawContent?.doc ?? block.rawContent
104
+ if (raw?.type === 'doc') return parseContent(raw).sequence
105
+ }
106
+
107
+ return null
108
+ }
109
+
110
+ /**
111
+ * Render an HTML paragraph fragment containing inline-inset markers
112
+ * (`<uniweb-inset data-ref-id="…">`). HTML chunks render via SafeHtml, marker
113
+ * positions via the framework's child-block renderer — the same path
114
+ * block-level insets take, scoped to one inset.
115
+ */
116
+ function renderParagraphWithInsets(html, block) {
117
+ if (!block) return <SafeHtml value={html} as="span" />
118
+ const InsetRenderer = getChildBlockRenderer()
119
+ const parts = []
120
+ let lastIdx = 0
121
+ INLINE_INSET_RE.lastIndex = 0
122
+ let match
123
+ while ((match = INLINE_INSET_RE.exec(html)) !== null) {
124
+ if (match.index > lastIdx) {
125
+ parts.push(<SafeHtml key={`t${lastIdx}`} value={html.slice(lastIdx, match.index)} as="span" />)
126
+ }
127
+ const refId = match[1]
128
+ const insetBlock = block.getInset?.(refId)
129
+ if (insetBlock && InsetRenderer) {
130
+ parts.push(<InsetRenderer key={`i${refId}`} blocks={[insetBlock]} />)
131
+ }
132
+ lastIdx = match.index + match[0].length
133
+ }
134
+ if (lastIdx < html.length) {
135
+ parts.push(<SafeHtml key={`t${lastIdx}`} value={html.slice(lastIdx)} as="span" />)
136
+ }
137
+ return parts
138
+ }
139
+
140
+ /** Render a list of sequence elements. */
141
+ function renderAll(sequence, block, components) {
142
+ return (sequence || []).map((element, i) => (
143
+ <SequenceElement key={i} element={element} block={block} components={components} />
144
+ ))
145
+ }
146
+
147
+ /**
148
+ * One element of a sequence.
149
+ *
150
+ * A `components` entry for the element's type replaces the default entirely and
151
+ * receives `{ element, block, children }` — `children` being the rendered
152
+ * sub-elements for the container types, so an override can rewrap without
153
+ * re-implementing the walk.
154
+ */
155
+ export function SequenceElement({ element, block, components }) {
156
+ if (!element) return null
157
+
158
+ const Override = components?.[element.type]
159
+ if (Override) {
160
+ const children = element.children ? renderAll(element.children, block, components) : undefined
161
+ return <Override element={element} block={block} children={children} />
162
+ }
163
+
164
+ switch (element.type) {
165
+ case 'heading': {
166
+ const level = Math.min(element.level || 1, 6)
167
+ const Tag = `h${level}`
168
+ // The id is the anchor `useHeadings()` and every in-page nav link
169
+ // resolve against, so it is behaviour rather than decoration.
170
+ return (
171
+ <Tag id={headingId(element.text || '')}>
172
+ <SafeHtml value={element.text} as="span" />
173
+ </Tag>
174
+ )
175
+ }
176
+
177
+ case 'paragraph': {
178
+ if (!element.text) return null
179
+ if (/<uniweb-inset/.test(element.text)) {
180
+ return <p>{renderParagraphWithInsets(element.text, block)}</p>
181
+ }
182
+ return (
183
+ <p>
184
+ <SafeHtml value={element.text} as="span" />
185
+ </p>
186
+ )
187
+ }
188
+
189
+ case 'image': {
190
+ const { url, src, alt, caption, role } = element.attrs || {}
191
+ if (role === 'icon') return <Icon {...element.attrs} />
192
+ return (
193
+ <figure>
194
+ <Image src={url || src} alt={alt || caption || ''} />
195
+ {caption && <figcaption>{caption}</figcaption>}
196
+ </figure>
197
+ )
198
+ }
199
+
200
+ // Spread rather than cherry-pick: a markdown video carries poster and the
201
+ // playback flags, and <Media> reads all of them.
202
+ case 'video':
203
+ return <Media {...element.attrs} />
204
+
205
+ case 'icon':
206
+ return <Icon {...element.attrs} />
207
+
208
+ case 'codeBlock':
209
+ return <Code content={element.text || ''} language={element.attrs?.language || ''} />
210
+
211
+ case 'math': {
212
+ // MathML is pre-compiled at parse time; the browser renders it natively,
213
+ // so no runtime math library ships. Missing here until 2026-07-30, which
214
+ // is why math vanished from every <Article>.
215
+ if (!element.mathml) return null
216
+ const display = element.display !== false
217
+ const Tag = display ? 'div' : 'span'
218
+ return (
219
+ <Tag
220
+ className={display ? 'math-display' : 'math-inline'}
221
+ dangerouslySetInnerHTML={{ __html: element.mathml }}
222
+ />
223
+ )
224
+ }
225
+
226
+ case 'list': {
227
+ const Tag = element.style === 'ordered' ? 'ol' : 'ul'
228
+ return (
229
+ <Tag>
230
+ {element.children?.map((itemSequence, i) => (
231
+ <li key={i}>{renderAll(itemSequence, block, components)}</li>
232
+ ))}
233
+ </Tag>
234
+ )
235
+ }
236
+
237
+ case 'table': {
238
+ // Semantic markup, no decoration: `<thead>` when the first row is
239
+ // headers, `<th>`/`<td>` from each cell's own flag, and alignment and
240
+ // spans carried through. Cells render their nested sequence, so a cell
241
+ // keeps its marks and may hold any block.
242
+ const rows = element.rows || []
243
+ if (!rows.length) return null
244
+ const [first, ...rest] = rows
245
+ const hasHeader = first.cells?.some((cell) => cell.header)
246
+
247
+ const renderRow = (row, key) => (
248
+ <tr key={key}>
249
+ {row.cells?.map((cell, i) => {
250
+ const CellTag = cell.header ? 'th' : 'td'
251
+ return (
252
+ <CellTag
253
+ key={i}
254
+ style={cell.align ? { textAlign: cell.align } : undefined}
255
+ colSpan={cell.colspan > 1 ? cell.colspan : undefined}
256
+ rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined}
257
+ >
258
+ {renderAll(cell.children, block, components)}
259
+ </CellTag>
260
+ )
261
+ })}
262
+ </tr>
263
+ )
264
+
265
+ // The one class the engine emits, and it is behaviour rather than
266
+ // decoration: a table wider than its column has to scroll inside its own
267
+ // box or it pushes the whole page sideways. Nothing about how the table
268
+ // LOOKS is decided here — borders, padding and header weight come from
269
+ // the typography layer, which answers to the site's theme.yml.
270
+ //
271
+ // Kept deliberately when the rest of kit's table styling went: a real
272
+ // docs site (uniweb-site) has no table CSS of its own and relied on kit
273
+ // for exactly this, so dropping it would have broken every wide
274
+ // reference table on a phone.
275
+ return (
276
+ <div className="overflow-x-auto">
277
+ <table>
278
+ {hasHeader && <thead>{renderRow(first, 'h')}</thead>}
279
+ <tbody>{(hasHeader ? rest : rows).map((row, i) => renderRow(row, i))}</tbody>
280
+ </table>
281
+ </div>
282
+ )
283
+ }
284
+
285
+ case 'blockquote':
286
+ return <blockquote>{renderAll(element.children, block, components)}</blockquote>
287
+
288
+ case 'inset_block': {
289
+ // A container reaching this case was NOT lifted — @uniweb/core rewrites
290
+ // every `inset_block` to an `inset_placeholder` when it builds the render
291
+ // graph, so the normal path is the `inset` case below, which resolves the
292
+ // component against the FOUNDATION. This is what is left for a document
293
+ // rendered without a Block behind it.
294
+ //
295
+ // kit does not map the name to one of its own components. A foundation
296
+ // shipping its own Alert would be shadowed by ours, which inverts who
297
+ // owns a site's vocabulary. A visible container that keeps its body,
298
+ // never a drop.
299
+ return (
300
+ <div data-inset-block={element.component || 'unknown'}>
301
+ {renderAll(element.children, block, components)}
302
+ </div>
303
+ )
304
+ }
305
+
306
+ case 'concept_block': {
307
+ // A tagged prose fence (```md:faq). Same rule one layer over: the tag
308
+ // names CONTENT, and the set of concepts belongs to whoever edits or
309
+ // renders it. The tag rides on a data attribute where a foundation's CSS
310
+ // can reach it — or it passes `components={{ concept_block: … }}` and
311
+ // renders the concept itself.
312
+ return (
313
+ <div data-concept={element.tag || 'unknown'}>
314
+ {renderAll(element.children, block, components)}
315
+ </div>
316
+ )
317
+ }
318
+
319
+ case 'inset': {
320
+ if (!block || !element.refId) return null
321
+ const insetBlock = block.getInset?.(element.refId)
322
+ if (!insetBlock) return null
323
+ const InsetRenderer = getChildBlockRenderer()
324
+ if (!InsetRenderer) return null
325
+ return <InsetRenderer blocks={[insetBlock]} />
326
+ }
327
+
328
+ // A link promoted out of a paragraph — it stood alone on its line, which is
329
+ // how an author writes a call to action.
330
+ case 'link': {
331
+ const { href, label } = element.attrs || {}
332
+ return (
333
+ <p>
334
+ <Link to={href}>{label}</Link>
335
+ </p>
336
+ )
337
+ }
338
+
339
+ case 'button':
340
+ return (
341
+ <p>
342
+ <Link to={element.attrs?.href}>{element.text}</Link>
343
+ </p>
344
+ )
345
+
346
+ // `type` is content — `---{type=dots}` is a different divider, not a
347
+ // differently-styled one — so this resolves it rather than emitting <hr>.
348
+ case 'divider':
349
+ return <Divider type={element.attrs?.type} />
350
+
351
+ default:
352
+ return null
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Render — walk a content sequence and emit React.
358
+ *
359
+ * Returns a FRAGMENT. No wrapper, no classes: spacing and typography are the
360
+ * caller's, which is what makes this usable as an engine. `<Prose>` supplies a
361
+ * typography container; a foundation with its own type system supplies nothing
362
+ * and lays the elements out itself.
363
+ *
364
+ * @param {Object} props
365
+ * @param {Object|Array} [props.content] - Parsed content (`.sequence`), a
366
+ * ProseMirror doc, or an array of ProseMirror nodes
367
+ * @param {Object} [props.block] - Block instance; supplies content when none is
368
+ * passed, and resolves insets
369
+ * @param {Object} [props.components] - Per-element-type renderer overrides
370
+ */
371
+ export function Render({ content, block, components }) {
372
+ const sequence = toSequence(content, block)
373
+ if (!sequence?.length) return null
374
+ return <>{renderAll(sequence, block, components)}</>
375
+ }
376
+
377
+ export default Render
@@ -10,7 +10,7 @@
10
10
  import React from 'react'
11
11
  import { cn } from '../../utils/index.js'
12
12
  import { useWebsite } from '../../hooks/useWebsite.js'
13
- import { Render } from './Render.jsx'
13
+ import { Prose } from '../Prose/index.jsx'
14
14
 
15
15
  /**
16
16
  * Width presets
@@ -115,11 +115,17 @@ export function Section({
115
115
  columns !== '1' && 'grid gap-8',
116
116
  columns !== '1' && columnClass
117
117
  )}>
118
- {children || (
119
- <div className="prose prose-gray max-w-none">
120
- <Render content={resolvedContent} />
121
- </div>
122
- )}
118
+ {/*
119
+ `prose-gray` was here until 2026-07-30, and prose-tokens.css
120
+ forbids it in as many words: a palette modifier "re-declares every
121
+ variable below", so it overrode the bridge that makes a prose
122
+ container answer to the site's theme.yml — the one thing that file
123
+ exists to do. Every Section rendering its own content styled its
124
+ prose with fixed Tailwind greys instead of the site's palette.
125
+ no-fixed-palettes did not catch it because it matches colour
126
+ UTILITIES (bg-, text-, border-), and this is a Typography modifier.
127
+ */}
128
+ {children || <Prose content={resolvedContent} />}
123
129
  </div>
124
130
  )}
125
131
  </div>
@@ -5,5 +5,4 @@
5
5
  */
6
6
 
7
7
  export { Section, default } from './Section.jsx'
8
- export { Render } from './Render.jsx'
9
8
  export * from './renderers/index.js'
@@ -20,7 +20,8 @@
20
20
  // ============================================================================
21
21
 
22
22
  // Section - Rich content section layout container
23
- export { Section, Render } from './Section/index.js'
23
+ export { Section } from './Section/index.js'
24
+ export { Render, SequenceElement } from './Render/index.jsx'
24
25
 
25
26
  // Prose - Typography wrapper for long-form content
26
27
  export { Prose } from './Prose/index.jsx'