@uniweb/kit 0.9.46 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.46",
3
+ "version": "0.10.0",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,7 +40,8 @@
40
40
  "fuse.js": "^7.0.0",
41
41
  "shiki": "^3.0.0",
42
42
  "tailwind-merge": "^3.6.0",
43
- "@uniweb/core": "0.7.34",
43
+ "@uniweb/semantic-parser": "1.2.0",
44
+ "@uniweb/core": "0.8.0",
44
45
  "@uniweb/scene": "0.1.2"
45
46
  },
46
47
  "peerDependencies": {
@@ -44,7 +44,7 @@
44
44
  * }
45
45
  */
46
46
 
47
- import { getUniweb } from '@uniweb/core'
47
+ import { getUniweb, recordDataUrl } from '@uniweb/core'
48
48
  import { useFetched } from './useFetched.js'
49
49
 
50
50
  /**
@@ -63,7 +63,19 @@ export function useEntityDetail(record, options = {}) {
63
63
  return useFetched(request)
64
64
  }
65
65
 
66
- function buildDetailRequest(record, collection) {
66
+ /**
67
+ * Build the fetch request for one record's full payload.
68
+ *
69
+ * Exported for tests only — not re-exported from the package index. The
70
+ * static-file branch has to resolve to the same URL the build writes and the
71
+ * same URL core's dynamic-route auto-detail injects; a test can only assert
72
+ * that if it can call this without a React tree.
73
+ *
74
+ * @param {Object|null} record
75
+ * @param {string} collection
76
+ * @returns {{path?: string, url?: string, schema: string}|null}
77
+ */
78
+ export function buildDetailRequest(record, collection) {
67
79
  const slug = record?.slug
68
80
  if (!slug || !collection) return null
69
81
 
@@ -83,10 +95,11 @@ function buildDetailRequest(record, collection) {
83
95
  return { url, schema: collection }
84
96
  }
85
97
 
86
- // Static-file default — the build emitted per-record JSON files.
87
- // Phase 5 of the CDN migration moved storage from /data/ to /_data/;
88
- // the site router accepts both URL shapes during the transition.
89
- return { path: `/_data/${collection}/${slug}.json`, schema: collection }
98
+ // Static-file default — the build emitted per-record JSON files. Shares
99
+ // `recordDataUrl` with the build that writes them and with core's
100
+ // dynamic-route auto-detail injection, which is the other half of this
101
+ // same feature: the three must resolve one record to one URL.
102
+ return { path: recordDataUrl(collection, slug), schema: collection }
90
103
  }
91
104
 
92
105
  export default useEntityDetail
package/src/index.js CHANGED
@@ -131,7 +131,8 @@ export {
131
131
  // Styled Components (Tailwind-based)
132
132
  // ============================================================================
133
133
 
134
- export { Section, Render } from './styled/Section/index.js'
134
+ export { Section } from './styled/Section/index.js'
135
+ export { Render, SequenceElement } from './styled/Render/index.jsx'
135
136
  export { Prose } from './styled/Prose/index.jsx'
136
137
  export { Article } from './styled/Article/index.jsx'
137
138
  export { Code, Alert, Warning, Table, Details, Divider } from './styled/Section/renderers/index.js'
@@ -1,84 +1,54 @@
1
1
  /**
2
- * Article Component
2
+ * Article — a document, in a semantic <article>.
3
3
  *
4
- * Semantic article wrapper with prose typography.
5
- * Use for blog posts, news articles, documentation pages, etc.
4
+ * `<Prose>` with the right tag. That is the whole component now, and the point
5
+ * of it is the element: an article is a self-contained composition, and saying
6
+ * so in markup is what assistive technology and reader modes act on.
7
+ *
8
+ * It used to be its own thing — a prose container wrapped around a SECOND node
9
+ * walker that read raw ProseMirror. That is why articles rendered with no bold,
10
+ * no links and no math until 2026-07-30: the walker's `paragraph` case
11
+ * flattened inline content to plain text, and it had no case for math at all.
12
+ * Going through `<Render>` means there is one walk, and inline content arrives
13
+ * from the parser already correct.
6
14
  *
7
15
  * @module @uniweb/kit/styled/Article
8
16
  */
9
17
 
10
18
  import React from 'react'
11
- import { cn } from '../../utils/index.js'
12
- import { Render } from '../Section/Render.jsx'
13
-
14
- /**
15
- * Prose sizes
16
- */
17
- const SIZE_CLASSES = {
18
- sm: 'prose-sm',
19
- base: 'prose-base',
20
- lg: 'prose-lg',
21
- xl: 'prose-xl',
22
- '2xl': 'prose-2xl'
23
- }
19
+ import { Prose } from '../Prose/index.jsx'
24
20
 
25
21
  /**
26
22
  * Article - Semantic article with prose typography
27
23
  *
28
- * Renders content inside a semantic <article> tag with prose styling.
29
- * Can accept either children or a content prop (ProseMirror JSON).
30
- *
31
24
  * @param {Object} props
32
- * @param {Object|Array} [props.content] - ProseMirror content to render
25
+ * @param {Object|Array} [props.content] - Parsed content (`.sequence`), a
26
+ * ProseMirror doc, or an array of ProseMirror nodes
27
+ * @param {Object} [props.block] - Block instance (supplies content; resolves insets)
28
+ * @param {Object} [props.components] - Per-element-type renderer overrides
33
29
  * @param {string} [props.size='lg'] - Text size: sm, base, lg, xl, 2xl
34
30
  * @param {string} [props.className] - Additional CSS classes
35
- * @param {React.ReactNode} [props.children] - Content to render (alternative to content prop)
31
+ * @param {React.ReactNode} [props.children] - Content to render (alternative to content)
36
32
  *
37
33
  * @example
38
- * // With ProseMirror content
39
34
  * <Article content={articleData.content} />
40
35
  *
41
36
  * @example
42
- * // With children
43
- * <Article>
44
- * <h1>My Article</h1>
45
- * <p>Article content...</p>
46
- * </Article>
47
- *
48
- * @example
49
- * // Composing with Render
50
- * <Article size="base" className="dark:prose-invert">
51
- * <Render content={proseMirrorContent} />
52
- * </Article>
37
+ * <Article block={block} components={{ blockquote: PullQuote }} />
53
38
  */
54
- export function Article({
55
- content,
56
- block,
57
- size = 'lg',
58
- className,
59
- children,
60
- ...props
61
- }) {
62
- const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.lg
63
-
64
- // Get content from block if not provided directly
65
- let resolvedContent = content
66
- if (!resolvedContent && block) {
67
- resolvedContent = block.rawContent
68
- }
69
-
70
- // If it's a ProseMirror doc, get the content array
71
- if (resolvedContent?.type === 'doc') {
72
- resolvedContent = resolvedContent.content
73
- }
74
-
39
+ export function Article({ content, block, components, size = 'lg', className, children, ...props }) {
75
40
  return (
76
- <article
77
- className={cn('prose', sizeClass, 'max-w-none', className)}
41
+ <Prose
42
+ as="article"
43
+ content={content}
44
+ block={block}
45
+ components={components}
46
+ size={size}
47
+ className={className}
78
48
  {...props}
79
49
  >
80
- {children || (resolvedContent && <Render content={resolvedContent} />)}
81
- </article>
50
+ {children}
51
+ </Prose>
82
52
  )
83
53
  }
84
54
 
@@ -202,11 +202,34 @@ function PlayButton({ onClick, className }) {
202
202
  * @example
203
203
  * // Local video
204
204
  * <Media src="/videos/intro.mp4" controls autoplay={false} />
205
+ *
206
+ * @example
207
+ * // A video authored in markdown, delivered via content.videos[]
208
+ * // ![Demo](./demo.mp4){role=video poster=./thumb.jpg autoplay muted loop}
209
+ * <Visual video={content.videos[0]} />
205
210
  */
206
211
  export function Media({
207
212
  src,
208
213
  media,
209
214
  thumbnail,
215
+ // Same concept, three producers. `thumbnail` is this component's own prop;
216
+ // `poster` is the authored markdown spelling (`{role=video poster=…}`, and
217
+ // what @uniweb/build auto-generates when it is absent); `coverImg` is what
218
+ // the editor's Video node carries. All three named here because a video
219
+ // entry from `content.videos[]` is spread straight in — `<Media {...video} />`
220
+ // via <Visual> — so an unrecognised one would land on the wrapper div, where
221
+ // it does nothing and React warns.
222
+ poster,
223
+ coverImg,
224
+ // Also part of the delivered video shape. Absorbed rather than forwarded:
225
+ // the reference documents wrapping media in a link as the FOUNDATION's job
226
+ // (docs/reference/content-structure.md, "Clickable Images and Videos"), so
227
+ // these are the author's to use, not this component's to consume.
228
+ alt,
229
+ role,
230
+ href,
231
+ target,
232
+ caption: captionProp,
210
233
  autoplay = false,
211
234
  muted = false,
212
235
  loop = false,
@@ -222,13 +245,14 @@ export function Media({
222
245
 
223
246
  // Normalize source
224
247
  const videoSrc = typeof src === 'string' ? src : (src?.src || media?.src || '')
225
- const caption = media?.caption || src?.caption || ''
248
+ const caption = captionProp || media?.caption || src?.caption || ''
226
249
 
227
250
  // Detect video type
228
251
  const mediaType = detectMediaType(videoSrc)
229
252
 
230
253
  // Get thumbnail
231
- const thumbnailSrc = thumbnail || getVideoThumbnail(videoSrc, mediaType)
254
+ const thumbnailSrc =
255
+ thumbnail || poster || coverImg || getVideoThumbnail(videoSrc, mediaType)
232
256
 
233
257
  // Handle play click (for facade mode)
234
258
  const handlePlay = useCallback(() => {
@@ -1,27 +1,29 @@
1
1
  /**
2
- * Prose Component
2
+ * Prose — content plus prose typography.
3
3
  *
4
- * Renders the prose (narrative) portions of parsed content from content.sequence.
5
- * Data blocks (tagged code blocks, dataBlocks) are skipped they're structured
6
- * data, not prose. Access them via content.data instead.
4
+ * `<Render>` walks the sequence; this adds the Tailwind Typography container
5
+ * that styles the semantic markup it emits. Two concerns, deliberately separate:
6
+ * a foundation with its own type system uses `<Render>` and lays out the
7
+ * elements itself.
7
8
  *
8
- * Also works as a pure typography wrapper when given children instead of content.
9
+ * Typography comes from a plugin the FOUNDATION installs kit ships no
10
+ * stylesheet — and answers to the site's `theme.yml` through
11
+ * `@uniweb/kit/prose-tokens.css`. See that file for the two rules that bite:
12
+ * one prose container per subtree (the variables are inherited, so a nested one
13
+ * silently resets its subtree), and no palette modifier (`prose-gray` and
14
+ * friends re-declare every variable and defeat the theme bridge).
15
+ *
16
+ * Also works as a pure typography wrapper when given children instead of
17
+ * content.
9
18
  *
10
19
  * @module @uniweb/kit/styled/Prose
11
20
  */
12
21
 
13
22
  import React from 'react'
14
- import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
15
- import { SafeHtml } from '../../components/SafeHtml/index.js'
16
- import { Image } from '../../components/Image/index.js'
17
- import { Media } from '../../components/Media/index.js'
18
- import { Icon } from '../../components/Icon/index.js'
19
- import { Link } from '../../components/Link/index.js'
20
- import { Code } from '../Section/renderers/Code.jsx'
23
+ import { cn } from '../../utils/index.js'
24
+ import { Render } from '../Render/index.jsx'
21
25
 
22
- /**
23
- * Prose sizes
24
- */
26
+ /** Prose sizes */
25
27
  const SIZE_CLASSES = {
26
28
  sm: 'prose-sm',
27
29
  base: 'prose-base',
@@ -30,215 +32,14 @@ const SIZE_CLASSES = {
30
32
  '2xl': 'prose-2xl'
31
33
  }
32
34
 
33
-
34
- const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
35
-
36
35
  /**
37
- * Render an HTML paragraph fragment that contains inline-inset markers
38
- * (`<uniweb-inset data-ref-id="…">`). The fragment is split at marker
39
- * boundaries; HTML chunks render via SafeHtml, marker positions render
40
- * via the framework's child-block renderer (the same path block-level
41
- * insets use, scoped to a single inset block).
42
- *
43
- * Components rendered through this path return an inline element
44
- * (typically a `<span>`) so the paragraph stays a single line of prose.
45
- */
46
- function renderParagraphWithInsets(html, block) {
47
- if (!block) return <SafeHtml value={html} as="span" />
48
- const InsetRenderer = getChildBlockRenderer()
49
- const parts = []
50
- let lastIdx = 0
51
- INLINE_INSET_RE.lastIndex = 0
52
- let match
53
- while ((match = INLINE_INSET_RE.exec(html)) !== null) {
54
- if (match.index > lastIdx) {
55
- parts.push(
56
- <SafeHtml
57
- key={`t${lastIdx}`}
58
- value={html.slice(lastIdx, match.index)}
59
- as="span"
60
- />
61
- )
62
- }
63
- const refId = match[1]
64
- const insetBlock = block.getInset?.(refId)
65
- if (insetBlock && InsetRenderer) {
66
- parts.push(<InsetRenderer key={`i${refId}`} blocks={[insetBlock]} />)
67
- }
68
- lastIdx = match.index + match[0].length
69
- }
70
- if (lastIdx < html.length) {
71
- parts.push(
72
- <SafeHtml key={`t${lastIdx}`} value={html.slice(lastIdx)} as="span" />
73
- )
74
- }
75
- return parts
76
- }
77
-
78
- /**
79
- * Render a single sequence element to React
80
- */
81
- function SequenceElement({ element, block }) {
82
- if (!element) return null
83
-
84
- switch (element.type) {
85
- case 'heading': {
86
- const level = Math.min(element.level || 1, 6)
87
- const Tag = `h${level}`
88
- const id = headingId(element.text || '')
89
- return <Tag id={id}><SafeHtml value={element.text} as="span" /></Tag>
90
- }
91
-
92
- case 'paragraph': {
93
- if (!element.text) return null
94
- // Inline insets ride through the paragraph text as
95
- // `<uniweb-inset data-ref-id="…"></uniweb-inset>` markers (see
96
- // semantic-parser's getTextContent). When any are present, walk
97
- // the text once and intersperse React-rendered insets at the
98
- // marker positions; otherwise stick to the fast SafeHtml path.
99
- if (/<uniweb-inset/.test(element.text)) {
100
- return <p>{renderParagraphWithInsets(element.text, block)}</p>
101
- }
102
- return <p><SafeHtml value={element.text} as="span" /></p>
103
- }
104
-
105
- case 'image': {
106
- const { url, alt, caption, role } = element.attrs || {}
107
- if (role === 'icon') {
108
- return <Icon {...element.attrs} />
109
- }
110
- return (
111
- <figure>
112
- <Image src={url} alt={alt || caption || ''} />
113
- {caption && <figcaption>{caption}</figcaption>}
114
- </figure>
115
- )
116
- }
117
-
118
- case 'video': {
119
- return <Media src={element.attrs?.src} />
120
- }
121
-
122
- case 'codeBlock': {
123
- return <Code content={element.text || ''} language={element.attrs?.language || ''} />
124
- }
125
-
126
- case 'dataBlock':
127
- return null
128
-
129
- case 'math': {
130
- // Math from $$...$$ on its own line, ```math fence, or inline
131
- // $...$ (the inline form doesn't reach the sequence walker — it
132
- // rides inside paragraph HTML — but render the same way for
133
- // safety in case a future caller surfaces it here). Mathml is
134
- // pre-compiled at parse time; the browser renders real MathML
135
- // natively. Foundations that want to style errors can target
136
- // .temml-error inside this wrapper.
137
- if (!element.mathml) return null
138
- const display = element.display !== false
139
- const Tag = display ? 'div' : 'span'
140
- return (
141
- <Tag
142
- className={display ? 'math-display' : 'math-inline'}
143
- dangerouslySetInnerHTML={{ __html: element.mathml }}
144
- />
145
- )
146
- }
147
-
148
- case 'list': {
149
- const Tag = element.style === 'ordered' ? 'ol' : 'ul'
150
- return (
151
- <Tag>
152
- {element.children?.map((itemSeq, i) => (
153
- <li key={i}>
154
- {itemSeq.map((el, j) => (
155
- <SequenceElement key={j} element={el} block={block} />
156
- ))}
157
- </li>
158
- ))}
159
- </Tag>
160
- )
161
- }
162
-
163
- case 'blockquote': {
164
- return (
165
- <blockquote>
166
- {element.children?.map((el, i) => (
167
- <SequenceElement key={i} element={el} block={block} />
168
- ))}
169
- </blockquote>
170
- )
171
- }
172
-
173
- case 'inset_block': {
174
- // A component reference that carries block content — the block form of
175
- // an inset. Children recurse; flattening them to text is what the
176
- // sequence's default branch used to do, and it lost the body.
177
- //
178
- // Reaching this case means the container was NOT lifted — `@uniweb/core`
179
- // rewrites `inset_block` to `inset_placeholder` when it builds the render
180
- // graph, and the `inset` case resolves that against the foundation. This
181
- // is the fallback for a document rendered without a Block behind it.
182
- //
183
- // kit does NOT map the name to one of its own components — see the longer
184
- // note in Section/Render. A visible generic box; never a drop.
185
- const body = element.children?.map((el, i) => (
186
- <SequenceElement key={i} element={el} block={block} />
187
- ))
188
-
189
- return (
190
- <div
191
- data-inset-block={element.component || 'unknown'}
192
- className="border border-border rounded-md p-4 my-4"
193
- >
194
- {body}
195
- </div>
196
- )
197
- }
198
-
199
- case 'link': {
200
- const { href, label, role } = element.attrs || {}
201
- // Standalone links promoted from paragraphs
202
- return <p><Link to={href}>{label}</Link></p>
203
- }
204
-
205
- case 'button': {
206
- return <p><Link to={element.attrs?.href}>{element.text}</Link></p>
207
- }
208
-
209
- case 'divider':
210
- return <hr />
211
-
212
- case 'inset': {
213
- if (!block || !element.refId) return null
214
- const insetBlock = block.getInset(element.refId)
215
- if (!insetBlock) return null
216
- const InsetRenderer = getChildBlockRenderer()
217
- if (!InsetRenderer) return null
218
- return <InsetRenderer blocks={[insetBlock]} />
219
- }
220
-
221
- case 'icon': {
222
- return <Icon {...element.attrs} />
223
- }
224
-
225
- default:
226
- return null
227
- }
228
- }
229
-
230
- /**
231
- * Prose - Renders the narrative content from a parsed content sequence
232
- *
233
- * Skips data blocks (tagged code blocks, dataBlocks) — those are structured
234
- * data accessible via content.data, not prose to render.
235
- *
236
- * Pass `content` (the parsed content object from component props).
237
- * Optionally pass `block` for inset resolution.
36
+ * Prose - Rendered content with prose typography
238
37
  *
239
38
  * @param {Object} props
240
- * @param {Object} [props.content] - Parsed content object (has .sequence)
241
- * @param {Object} [props.block] - Block instance (needed for inset resolution)
39
+ * @param {Object|Array} [props.content] - Parsed content (`.sequence`), a
40
+ * ProseMirror doc, or an array of ProseMirror nodes
41
+ * @param {Object} [props.block] - Block instance (supplies content; resolves insets)
42
+ * @param {Object} [props.components] - Per-element-type renderer overrides
242
43
  * @param {string} [props.size='lg'] - Text size: sm, base, lg, xl, 2xl
243
44
  * @param {string} [props.as='div'] - HTML element to render as
244
45
  * @param {string} [props.className] - Additional CSS classes
@@ -251,20 +52,24 @@ function SequenceElement({ element, block }) {
251
52
  * }
252
53
  *
253
54
  * @example
254
- * // Access data blocks separately
55
+ * // Render one element type your own way — the foundation's design, kit's walk
56
+ * <Prose content={content} block={block} components={{ concept_block: Faq }} />
57
+ *
58
+ * @example
59
+ * // Access data blocks separately — they are deliberately not rendered
255
60
  * <Prose content={content} block={block} />
256
61
  * {content.data.quiz && <Quiz data={content.data.quiz} />}
257
62
  *
258
63
  * @example
259
- * // Pure typography wrapper (backward compatible)
64
+ * // Pure typography wrapper
260
65
  * <Prose size="base">
261
66
  * <h2>Title</h2>
262
- * <p>Content...</p>
263
67
  * </Prose>
264
68
  */
265
69
  export function Prose({
266
70
  block,
267
71
  content,
72
+ components,
268
73
  size = 'lg',
269
74
  as: Component = 'div',
270
75
  className,
@@ -272,19 +77,10 @@ export function Prose({
272
77
  ...props
273
78
  }) {
274
79
  const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.lg
275
- const sequence = content?.sequence
276
80
 
277
81
  return (
278
- <Component
279
- className={cn('prose', sizeClass, 'max-w-none', className)}
280
- {...props}
281
- >
282
- {sequence
283
- ? sequence.map((element, i) => (
284
- <SequenceElement key={i} element={element} block={block} />
285
- ))
286
- : children
287
- }
82
+ <Component className={cn('prose', sizeClass, 'max-w-none', className)} {...props}>
83
+ {children ?? <Render content={content} block={block} components={components} />}
288
84
  </Component>
289
85
  )
290
86
  }
@@ -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'
@@ -1,367 +0,0 @@
1
- /**
2
- * Render Component
3
- *
4
- * Orchestrates rendering of content blocks within a Section.
5
- * Dispatches to appropriate renderers based on content type.
6
- *
7
- * @module @uniweb/kit/Section/Render
8
- */
9
-
10
- import React from 'react'
11
- import { cn, getChildBlockRenderer, headingId } from '../../utils/index.js'
12
- import { SafeHtml } from '../../components/SafeHtml/index.js'
13
- import { Image } from '../../components/Image/index.js'
14
- import { Media } from '../../components/Media/index.js'
15
- import { Icon } from '../../components/Icon/index.js'
16
- import { Link } from '../../components/Link/index.js'
17
- import { Code } from './renderers/Code.jsx'
18
- import { Alert } from './renderers/Alert.jsx'
19
- import { Table } from './renderers/Table.jsx'
20
- import { Details } from './renderers/Details.jsx'
21
- import { Divider } from './renderers/Divider.jsx'
22
-
23
- /**
24
- * Extract text content from a node
25
- */
26
- function extractText(node) {
27
- if (!node) return ''
28
- if (typeof node === 'string') return node
29
- if (node.text) return node.text
30
- if (node.content) {
31
- return node.content.map(extractText).join('')
32
- }
33
- return ''
34
- }
35
-
36
-
37
- /**
38
- * Render a list (ordered or unordered)
39
- */
40
- function renderList(items, ordered = false) {
41
- const Tag = ordered ? 'ol' : 'ul'
42
- const listClass = ordered ? 'list-decimal' : 'list-disc'
43
-
44
- return (
45
- <Tag className={cn('pl-6 space-y-1', listClass)}>
46
- {items?.map((item, i) => (
47
- <li key={i}>
48
- {item.content?.map((child, j) => (
49
- <RenderNode key={j} node={child} />
50
- ))}
51
- </li>
52
- ))}
53
- </Tag>
54
- )
55
- }
56
-
57
- /**
58
- * Render a single content node
59
- */
60
- function RenderNode({ node, block, ...props }) {
61
- if (!node) return null
62
-
63
- const { type, attrs, content } = node
64
-
65
- switch (type) {
66
- case 'paragraph': {
67
- const html = extractText(node)
68
- if (!html) return null
69
- return <p><SafeHtml value={html} as="span" /></p>
70
- }
71
-
72
- case 'heading': {
73
- const level = attrs?.level || 1
74
- const text = extractText(node)
75
- const id = headingId(text)
76
- const Tag = `h${Math.min(level, 6)}`
77
-
78
- return (
79
- <Tag id={id} className="scroll-mt-20">
80
- {text}
81
- </Tag>
82
- )
83
- }
84
-
85
- case 'image': {
86
- const src = attrs?.src || ''
87
- const alt = attrs?.alt || ''
88
- const caption = attrs?.caption || ''
89
- const role = attrs?.role
90
-
91
- // Dispatch based on role attribute
92
- if (role === 'video') {
93
- // Video content - use Media component
94
- return (
95
- <Media
96
- src={src}
97
- autoplay={attrs?.autoplay}
98
- muted={attrs?.muted}
99
- loop={attrs?.loop}
100
- controls={attrs?.controls}
101
- className="my-4 rounded-lg overflow-hidden"
102
- />
103
- )
104
- }
105
-
106
- if (role === 'document') {
107
- // Document/file link with optional preview
108
- const poster = attrs?.poster
109
- const preview = attrs?.preview
110
- const filename = alt || src.split('/').pop() || 'Document'
111
-
112
- return (
113
- <figure className="my-4">
114
- <Link
115
- to={src}
116
- className="block group border rounded-lg overflow-hidden hover:shadow-md transition-shadow"
117
- target="_blank"
118
- >
119
- {(poster || preview) ? (
120
- <Image
121
- src={poster || preview}
122
- alt={filename}
123
- className="w-full"
124
- />
125
- ) : (
126
- <div className="flex items-center gap-3 p-4 bg-muted">
127
- <Icon name="download" size="24" className="text-subtle" />
128
- <span className="text-link group-hover:underline">
129
- {filename}
130
- </span>
131
- </div>
132
- )}
133
- </Link>
134
- {caption && (
135
- <figcaption className="mt-2 text-sm text-subtle text-center">
136
- {caption}
137
- </figcaption>
138
- )}
139
- </figure>
140
- )
141
- }
142
-
143
- if (role === 'icon') {
144
- // Icon - use Icon component
145
- // Supports: ![alt](lucide:icon-name){size=24 color=blue}
146
- // ![alt](icon:/path/to/icon.svg){size=32}
147
- const size = attrs?.size || '24'
148
- const iconName = attrs?.name || alt
149
- const iconColor = attrs?.color
150
-
151
- return (
152
- <Icon
153
- url={src}
154
- name={iconName}
155
- size={size}
156
- color={iconColor}
157
- className="inline-block"
158
- />
159
- )
160
- }
161
-
162
- // Default: image or banner - use Image component
163
- return (
164
- <figure className="my-4">
165
- <Image src={src} alt={alt} className="rounded-lg" />
166
- {caption && (
167
- <figcaption className="mt-2 text-sm text-subtle text-center">
168
- {caption}
169
- </figcaption>
170
- )}
171
- </figure>
172
- )
173
- }
174
-
175
- case 'video': {
176
- const src = attrs?.src || ''
177
- return <Media src={src} className="my-4 rounded-lg overflow-hidden" />
178
- }
179
-
180
- case 'codeBlock': {
181
- const language = attrs?.language || 'plaintext'
182
- const code = extractText(node)
183
- return <Code content={code} language={language} className="my-4" />
184
- }
185
-
186
- case 'warning':
187
- case 'alert': {
188
- const alertType = attrs?.type || 'info'
189
- const alertContent = content?.map(extractText).join('') || ''
190
- return <Alert type={alertType} content={alertContent} className="my-4" />
191
- }
192
-
193
- case 'blockquote': {
194
- return (
195
- <blockquote className="border-l-4 border-border pl-4 italic text-subtle my-4">
196
- {content?.map((child, i) => (
197
- <RenderNode key={i} node={child} block={block} />
198
- ))}
199
- </blockquote>
200
- )
201
- }
202
-
203
- case 'bulletList': {
204
- return renderList(content, false)
205
- }
206
-
207
- case 'orderedList': {
208
- return renderList(content, true)
209
- }
210
-
211
- case 'table': {
212
- return <Table content={content} className="my-4" />
213
- }
214
-
215
- case 'details': {
216
- const summary = attrs?.summary || 'Details'
217
- const detailsContent = content?.map(extractText).join('') || ''
218
- return (
219
- <Details
220
- summary={summary}
221
- content={detailsContent}
222
- open={attrs?.open}
223
- className="my-4"
224
- />
225
- )
226
- }
227
-
228
- case 'inset_block': {
229
- // The block form of an inset: a fenced `@Component{params}` container
230
- // whose body is real block content, recursed like a blockquote's rather
231
- // than flattened to a string.
232
- //
233
- // NOTE: a container reaching this case means it was NOT lifted.
234
- // `@uniweb/core`'s Block rewrites every `inset_block` to an
235
- // `inset_placeholder` when it builds the render graph, so the normal
236
- // path is the `inset_placeholder` case below — which resolves the
237
- // component against the FOUNDATION. This branch is what is left for a
238
- // document rendered without a Block behind it.
239
- //
240
- // `@Component` names foundation vocabulary, so kit must not answer for
241
- // it. kit's own Details and Alert are not reachable through
242
- // `getInset()` and must not become reachable here, or a foundation
243
- // shipping its own Alert would be shadowed by ours. (kit still renders
244
- // `details` / `alert` above — those are the editor's DOCUMENT node
245
- // types, a different mechanism that happens to share a name.)
246
- //
247
- // So: no name dispatch. A VISIBLE generic box that keeps its body.
248
- // Never a drop — an unmapped node taking its subtree with it is the
249
- // failure this container exists to fix.
250
- const component = attrs?.component
251
- const body = content?.map((child, i) => (
252
- <RenderNode key={i} node={child} block={block} />
253
- ))
254
-
255
- return (
256
- <div
257
- data-inset-block={component || 'unknown'}
258
- className="border border-border rounded-md p-4 my-4"
259
- >
260
- {body}
261
- </div>
262
- )
263
- }
264
-
265
- case 'horizontalRule':
266
- case 'divider': {
267
- return <Divider type={attrs?.type} className="my-6" />
268
- }
269
-
270
- case 'inset_placeholder': {
271
- const refId = attrs?.refId
272
- if (!block || !refId) return null
273
-
274
- const insetBlock = block.getInset(refId)
275
- if (!insetBlock) return null
276
-
277
- const InsetRenderer = getChildBlockRenderer()
278
- return <InsetRenderer blocks={[insetBlock]} />
279
- }
280
-
281
- case 'button': {
282
- const href = attrs?.href || '#'
283
- const label = extractText(node) || attrs?.label || 'Button'
284
- return (
285
- <Link
286
- to={href}
287
- className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary-hover transition-colors my-2"
288
- >
289
- {label}
290
- </Link>
291
- )
292
- }
293
-
294
- case 'text': {
295
- // Handle inline marks (bold, italic, etc.)
296
- let text = node.text || ''
297
-
298
- if (node.marks) {
299
- node.marks.forEach((mark) => {
300
- switch (mark.type) {
301
- case 'bold':
302
- case 'strong':
303
- text = `<strong>${text}</strong>`
304
- break
305
- case 'italic':
306
- case 'em':
307
- text = `<em>${text}</em>`
308
- break
309
- case 'code':
310
- text = `<code class="px-1 py-0.5 bg-muted rounded text-sm">${text}</code>`
311
- break
312
- case 'link':
313
- text = `<a href="${mark.attrs?.href || '#'}" class="text-link hover:underline">${text}</a>`
314
- break
315
- }
316
- })
317
- }
318
-
319
- return <SafeHtml value={text} as="span" />
320
- }
321
-
322
- default:
323
- // Try to render children if they exist
324
- if (content && Array.isArray(content)) {
325
- return (
326
- <>
327
- {content.map((child, i) => (
328
- <RenderNode key={i} node={child} block={block} />
329
- ))}
330
- </>
331
- )
332
- }
333
- return null
334
- }
335
- }
336
-
337
- /**
338
- * Render - Content block renderer
339
- *
340
- * @param {Object} props
341
- * @param {Array|Object} props.content - Content to render (array of nodes or single node)
342
- * @param {string} [props.className] - Additional CSS classes
343
- */
344
- export function Render({ content, block, className, ...props }) {
345
- // Get content from block if not provided directly
346
- let resolvedContent = content
347
- if (!resolvedContent && block) {
348
- resolvedContent = block.rawContent
349
- if (resolvedContent?.type === 'doc') {
350
- resolvedContent = resolvedContent.content
351
- }
352
- }
353
-
354
- if (!resolvedContent) return null
355
-
356
- const nodes = Array.isArray(resolvedContent) ? resolvedContent : [resolvedContent]
357
-
358
- return (
359
- <div className={cn('space-y-4', className)} {...props}>
360
- {nodes.map((node, i) => (
361
- <RenderNode key={i} node={node} block={block} />
362
- ))}
363
- </div>
364
- )
365
- }
366
-
367
- export default Render