@uniweb/kit 0.9.46 → 0.10.1

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,422 @@
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
+ * A table cell's contents, with a lone block UNWRAPPED.
149
+ *
150
+ * The schema declares `tableCell` as `paragraph+`, so a markdown cell is always
151
+ * exactly one block — and emitting its `<p>` faithfully is what a typography
152
+ * layer then gives paragraph margins to. Measured on a real docs site: 17.5px
153
+ * top and bottom inside every cell, turning a one-line reference row into 75px.
154
+ *
155
+ * TWO shapes reach a cell, which is easy to miss and was: an ordinary paragraph,
156
+ * and a `link` — because the parser PROMOTES a paragraph holding nothing but a
157
+ * link, which is what makes a link on its own line a call to action in prose.
158
+ * A cell whose whole content is a link is the same promotion arriving somewhere
159
+ * it means nothing, so it unwraps too. Fixing only the paragraph left every
160
+ * link-only cell tall, which is how this was found: 17 of them on one page.
161
+ *
162
+ * A cell holding genuinely several blocks still gets them, because that is what
163
+ * it is.
164
+ */
165
+ function renderCell(cell, block, components) {
166
+ const children = cell.children || []
167
+ if (children.length !== 1) return renderAll(children, block, components)
168
+
169
+ const [only] = children
170
+
171
+ if (only?.type === 'paragraph') {
172
+ if (!only.text) return null
173
+ return /<uniweb-inset/.test(only.text) ? (
174
+ renderParagraphWithInsets(only.text, block)
175
+ ) : (
176
+ <SafeHtml value={only.text} as="span" />
177
+ )
178
+ }
179
+
180
+ if (only?.type === 'link') {
181
+ const { href, label } = only.attrs || {}
182
+ return <Link to={href}>{label}</Link>
183
+ }
184
+
185
+ if (only?.type === 'button') {
186
+ return <Link to={only.attrs?.href}>{only.text}</Link>
187
+ }
188
+
189
+ return renderAll(children, block, components)
190
+ }
191
+
192
+ /**
193
+ * One element of a sequence.
194
+ *
195
+ * A `components` entry for the element's type replaces the default entirely and
196
+ * receives `{ element, block, children }` — `children` being the rendered
197
+ * sub-elements for the container types, so an override can rewrap without
198
+ * re-implementing the walk.
199
+ */
200
+ export function SequenceElement({ element, block, components }) {
201
+ if (!element) return null
202
+
203
+ const Override = components?.[element.type]
204
+ if (Override) {
205
+ const children = element.children ? renderAll(element.children, block, components) : undefined
206
+ return <Override element={element} block={block} children={children} />
207
+ }
208
+
209
+ switch (element.type) {
210
+ case 'heading': {
211
+ const level = Math.min(element.level || 1, 6)
212
+ const Tag = `h${level}`
213
+ // The id is the anchor `useHeadings()` and every in-page nav link
214
+ // resolve against, so it is behaviour rather than decoration.
215
+ return (
216
+ <Tag id={headingId(element.text || '')}>
217
+ <SafeHtml value={element.text} as="span" />
218
+ </Tag>
219
+ )
220
+ }
221
+
222
+ case 'paragraph': {
223
+ if (!element.text) return null
224
+ if (/<uniweb-inset/.test(element.text)) {
225
+ return <p>{renderParagraphWithInsets(element.text, block)}</p>
226
+ }
227
+ return (
228
+ <p>
229
+ <SafeHtml value={element.text} as="span" />
230
+ </p>
231
+ )
232
+ }
233
+
234
+ case 'image': {
235
+ const { url, src, alt, caption, role } = element.attrs || {}
236
+ if (role === 'icon') return <Icon {...element.attrs} />
237
+ return (
238
+ <figure>
239
+ <Image src={url || src} alt={alt || caption || ''} />
240
+ {caption && <figcaption>{caption}</figcaption>}
241
+ </figure>
242
+ )
243
+ }
244
+
245
+ // Spread rather than cherry-pick: a markdown video carries poster and the
246
+ // playback flags, and <Media> reads all of them.
247
+ case 'video':
248
+ return <Media {...element.attrs} />
249
+
250
+ case 'icon':
251
+ return <Icon {...element.attrs} />
252
+
253
+ case 'codeBlock':
254
+ return <Code content={element.text || ''} language={element.attrs?.language || ''} />
255
+
256
+ case 'math': {
257
+ // MathML is pre-compiled at parse time; the browser renders it natively,
258
+ // so no runtime math library ships. Missing here until 2026-07-30, which
259
+ // is why math vanished from every <Article>.
260
+ if (!element.mathml) return null
261
+ const display = element.display !== false
262
+ const Tag = display ? 'div' : 'span'
263
+ return (
264
+ <Tag
265
+ className={display ? 'math-display' : 'math-inline'}
266
+ dangerouslySetInnerHTML={{ __html: element.mathml }}
267
+ />
268
+ )
269
+ }
270
+
271
+ case 'list': {
272
+ const Tag = element.style === 'ordered' ? 'ol' : 'ul'
273
+ return (
274
+ <Tag>
275
+ {element.children?.map((itemSequence, i) => (
276
+ <li key={i}>{renderAll(itemSequence, block, components)}</li>
277
+ ))}
278
+ </Tag>
279
+ )
280
+ }
281
+
282
+ case 'table': {
283
+ // Semantic markup, no decoration: `<thead>` when the first row is
284
+ // headers, `<th>`/`<td>` from each cell's own flag, and alignment and
285
+ // spans carried through. Cells render their nested sequence, so a cell
286
+ // keeps its marks and may hold any block.
287
+ const rows = element.rows || []
288
+ if (!rows.length) return null
289
+ const [first, ...rest] = rows
290
+ const hasHeader = first.cells?.some((cell) => cell.header)
291
+
292
+ const renderRow = (row, key) => (
293
+ <tr key={key}>
294
+ {row.cells?.map((cell, i) => {
295
+ const CellTag = cell.header ? 'th' : 'td'
296
+ return (
297
+ <CellTag
298
+ key={i}
299
+ style={cell.align ? { textAlign: cell.align } : undefined}
300
+ colSpan={cell.colspan > 1 ? cell.colspan : undefined}
301
+ rowSpan={cell.rowspan > 1 ? cell.rowspan : undefined}
302
+ >
303
+ {renderCell(cell, block, components)}
304
+ </CellTag>
305
+ )
306
+ })}
307
+ </tr>
308
+ )
309
+
310
+ // The one class the engine emits, and it is behaviour rather than
311
+ // decoration: a table wider than its column has to scroll inside its own
312
+ // box or it pushes the whole page sideways. Nothing about how the table
313
+ // LOOKS is decided here — borders, padding and header weight come from
314
+ // the typography layer, which answers to the site's theme.yml.
315
+ //
316
+ // Kept deliberately when the rest of kit's table styling went: a real
317
+ // docs site (uniweb-site) has no table CSS of its own and relied on kit
318
+ // for exactly this, so dropping it would have broken every wide
319
+ // reference table on a phone.
320
+ return (
321
+ <div className="overflow-x-auto">
322
+ <table>
323
+ {hasHeader && <thead>{renderRow(first, 'h')}</thead>}
324
+ <tbody>{(hasHeader ? rest : rows).map((row, i) => renderRow(row, i))}</tbody>
325
+ </table>
326
+ </div>
327
+ )
328
+ }
329
+
330
+ case 'blockquote':
331
+ return <blockquote>{renderAll(element.children, block, components)}</blockquote>
332
+
333
+ case 'inset_block': {
334
+ // A container reaching this case was NOT lifted — @uniweb/core rewrites
335
+ // every `inset_block` to an `inset_placeholder` when it builds the render
336
+ // graph, so the normal path is the `inset` case below, which resolves the
337
+ // component against the FOUNDATION. This is what is left for a document
338
+ // rendered without a Block behind it.
339
+ //
340
+ // kit does not map the name to one of its own components. A foundation
341
+ // shipping its own Alert would be shadowed by ours, which inverts who
342
+ // owns a site's vocabulary. A visible container that keeps its body,
343
+ // never a drop.
344
+ return (
345
+ <div data-inset-block={element.component || 'unknown'}>
346
+ {renderAll(element.children, block, components)}
347
+ </div>
348
+ )
349
+ }
350
+
351
+ case 'concept_block': {
352
+ // A tagged prose fence (```md:faq). Same rule one layer over: the tag
353
+ // names CONTENT, and the set of concepts belongs to whoever edits or
354
+ // renders it. The tag rides on a data attribute where a foundation's CSS
355
+ // can reach it — or it passes `components={{ concept_block: … }}` and
356
+ // renders the concept itself.
357
+ return (
358
+ <div data-concept={element.tag || 'unknown'}>
359
+ {renderAll(element.children, block, components)}
360
+ </div>
361
+ )
362
+ }
363
+
364
+ case 'inset': {
365
+ if (!block || !element.refId) return null
366
+ const insetBlock = block.getInset?.(element.refId)
367
+ if (!insetBlock) return null
368
+ const InsetRenderer = getChildBlockRenderer()
369
+ if (!InsetRenderer) return null
370
+ return <InsetRenderer blocks={[insetBlock]} />
371
+ }
372
+
373
+ // A link promoted out of a paragraph — it stood alone on its line, which is
374
+ // how an author writes a call to action.
375
+ case 'link': {
376
+ const { href, label } = element.attrs || {}
377
+ return (
378
+ <p>
379
+ <Link to={href}>{label}</Link>
380
+ </p>
381
+ )
382
+ }
383
+
384
+ case 'button':
385
+ return (
386
+ <p>
387
+ <Link to={element.attrs?.href}>{element.text}</Link>
388
+ </p>
389
+ )
390
+
391
+ // `type` is content — `---{type=dots}` is a different divider, not a
392
+ // differently-styled one — so this resolves it rather than emitting <hr>.
393
+ case 'divider':
394
+ return <Divider type={element.attrs?.type} />
395
+
396
+ default:
397
+ return null
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Render — walk a content sequence and emit React.
403
+ *
404
+ * Returns a FRAGMENT. No wrapper, no classes: spacing and typography are the
405
+ * caller's, which is what makes this usable as an engine. `<Prose>` supplies a
406
+ * typography container; a foundation with its own type system supplies nothing
407
+ * and lays the elements out itself.
408
+ *
409
+ * @param {Object} props
410
+ * @param {Object|Array} [props.content] - Parsed content (`.sequence`), a
411
+ * ProseMirror doc, or an array of ProseMirror nodes
412
+ * @param {Object} [props.block] - Block instance; supplies content when none is
413
+ * passed, and resolves insets
414
+ * @param {Object} [props.components] - Per-element-type renderer overrides
415
+ */
416
+ export function Render({ content, block, components }) {
417
+ const sequence = toSequence(content, block)
418
+ if (!sequence?.length) return null
419
+ return <>{renderAll(sequence, block, components)}</>
420
+ }
421
+
422
+ 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'