@uniweb/kit 0.9.46 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- 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 +422 -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
|
@@ -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
|