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