@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 +3 -2
- package/src/hooks/useEntityDetail.js +19 -6
- package/src/index.js +2 -1
- package/src/styled/Article/index.jsx +28 -58
- package/src/styled/Media/Media.jsx +26 -2
- package/src/styled/Prose/index.jsx +31 -235
- package/src/styled/Render/index.jsx +377 -0
- package/src/styled/Section/Section.jsx +12 -6
- package/src/styled/Section/index.js +0 -1
- package/src/styled/index.js +2 -1
- package/src/styled/Section/Render.jsx +0 -367
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
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
|
-
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
|
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
|
|
2
|
+
* Article — a document, in a semantic <article>.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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] -
|
|
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
|
|
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
|
-
*
|
|
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
|
-
<
|
|
77
|
-
|
|
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
|
|
81
|
-
</
|
|
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
|
+
* // {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 =
|
|
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
|
|
2
|
+
* Prose — content plus prose typography.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
15
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
241
|
-
*
|
|
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
|
-
* //
|
|
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
|
|
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
|
-
|
|
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 ``; 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 {
|
|
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
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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>
|
package/src/styled/index.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// ============================================================================
|
|
21
21
|
|
|
22
22
|
// Section - Rich content section layout container
|
|
23
|
-
export { Section
|
|
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: {size=24 color=blue}
|
|
146
|
-
// {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
|