@uniweb/kit 0.9.45 → 0.10.0

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