@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.
- package/package.json +3 -2
- package/src/hooks/useEntityDetail.js +19 -6
- package/src/hooks/useThemeData.js +6 -1
- 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
|
@@ -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'
|