@uniweb/kit 0.11.2 → 0.11.3

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.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,9 +44,9 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/core": "^0.8.4",
48
- "@uniweb/scene": "^0.1.3",
49
- "@uniweb/semantic-parser": "^1.2.1"
47
+ "@uniweb/core": "^0.8.5",
48
+ "@uniweb/semantic-parser": "^1.2.2",
49
+ "@uniweb/scene": "^0.1.3"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "^19.0.0",
@@ -38,6 +38,28 @@ const BUILT_IN_ICONS = {
38
38
  * @param {string} svgContent - Raw SVG string
39
39
  * @returns {Object} { viewBox, content, width, height }
40
40
  */
41
+ /**
42
+ * Coerce a size prop into a valid CSS length.
43
+ *
44
+ * React appends `px` to a NUMERIC style value but passes a string through
45
+ * verbatim — and this component's default is the string `'24'`, so it emitted
46
+ * `style="width:24;height:24"`. Both declarations are invalid CSS and the
47
+ * browser drops them, leaving the <svg> at its default replaced-element size.
48
+ *
49
+ * That stayed invisible for as long as every caller sized the icon with a class
50
+ * (`h-8 w-8`) or placed it where a stray size did not matter. It became visible
51
+ * when icons started rendering INLINE inside prose: with no valid width, the
52
+ * glyph could not share a line and broke the sentence across three lines.
53
+ *
54
+ * Only a bare number is touched, so `'2rem'`, `'1.5em'`, `'100%'` and a real
55
+ * number are all unchanged.
56
+ */
57
+ function cssLength(value) {
58
+ return typeof value === 'string' && /^-?\d*\.?\d+$/.test(value.trim())
59
+ ? `${value.trim()}px`
60
+ : value
61
+ }
62
+
41
63
  /**
42
64
  * Extract an attribute value from an SVG opening tag string
43
65
  */
@@ -253,7 +275,7 @@ export function Icon({
253
275
  loadingComponent || (
254
276
  <span
255
277
  className={cn('inline-flex items-center justify-center', className)}
256
- style={{ width: size, height: size }}
278
+ style={{ width: cssLength(size), height: cssLength(size) }}
257
279
  role="img"
258
280
  aria-hidden="true"
259
281
  />
@@ -287,7 +309,7 @@ export function Icon({
287
309
  return (
288
310
  <span
289
311
  className={cn('inline-flex items-center justify-center', className)}
290
- style={{ width: size, height: size }}
312
+ style={{ width: cssLength(size), height: cssLength(size) }}
291
313
  role="img"
292
314
  aria-hidden="true"
293
315
  />
@@ -298,8 +320,8 @@ export function Icon({
298
320
 
299
321
  // Build style
300
322
  const style = {
301
- width: size,
302
- height: size,
323
+ width: cssLength(size),
324
+ height: cssLength(size),
303
325
  ...(color && !preserveColors ? { color } : {})
304
326
  }
305
327
 
@@ -59,6 +59,22 @@ import { Divider } from '../Section/renderers/Divider.jsx'
59
59
 
60
60
  const INLINE_INSET_RE = /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>/g
61
61
 
62
+ /**
63
+ * Inline atoms the parser leaves as markers in a paragraph's HTML, because each
64
+ * needs a React component rather than markup: an inset resolves to a Block, an
65
+ * icon to <Icon> (which fetches library+name from the icon CDN).
66
+ *
67
+ * One regex for both so a paragraph carrying a mix is split in a single pass and
68
+ * every fragment keeps its authored position.
69
+ */
70
+ const INLINE_MARKER_RE =
71
+ /<uniweb-inset data-ref-id="([^"]+)"><\/uniweb-inset>|<uniweb-icon data-index="(\d+)"><\/uniweb-icon>/g
72
+
73
+ /** Does this HTML string carry any inline marker needing component substitution? */
74
+ function hasInlineMarkers(html) {
75
+ return typeof html === 'string' && /<uniweb-(?:inset|icon)\b/.test(html)
76
+ }
77
+
62
78
  /**
63
79
  * Element types this engine knows about and deliberately renders NOTHING for.
64
80
  *
@@ -130,22 +146,37 @@ function toSequence(content, block) {
130
146
  * positions via the framework's child-block renderer — the same path
131
147
  * block-level insets take, scoped to one inset.
132
148
  */
133
- function renderParagraphWithInsets(html, block) {
134
- if (!block) return <SafeHtml value={html} as="span" />
149
+ function renderParagraphWithInsets(html, block, element) {
150
+ // An icon needs no Block, so a paragraph carrying only icons still resolves
151
+ // without one — only insets require `block` for the getInset() lookup.
152
+ const icons = (element?.children || []).filter((c) => c?.type === 'icon')
153
+ if (!block && !icons.length) return <SafeHtml value={html} as="span" />
154
+
135
155
  const InsetRenderer = getChildBlockRenderer()
136
156
  const parts = []
137
157
  let lastIdx = 0
138
- INLINE_INSET_RE.lastIndex = 0
158
+ INLINE_MARKER_RE.lastIndex = 0
139
159
  let match
140
- while ((match = INLINE_INSET_RE.exec(html)) !== null) {
160
+ while ((match = INLINE_MARKER_RE.exec(html)) !== null) {
141
161
  if (match.index > lastIdx) {
142
162
  parts.push(<SafeHtml key={`t${lastIdx}`} value={html.slice(lastIdx, match.index)} as="span" />)
143
163
  }
164
+
144
165
  const refId = match[1]
145
- const insetBlock = block.getInset?.(refId)
146
- if (insetBlock && InsetRenderer) {
147
- parts.push(<InsetRenderer key={`i${refId}`} blocks={[insetBlock]} />)
166
+ const iconIndex = match[2]
167
+
168
+ if (refId !== undefined) {
169
+ const insetBlock = block?.getInset?.(refId)
170
+ if (insetBlock && InsetRenderer) {
171
+ parts.push(<InsetRenderer key={`i${refId}`} blocks={[insetBlock]} />)
172
+ }
173
+ } else if (iconIndex !== undefined) {
174
+ // Ordinal into the icons of this element's `children`, in document order
175
+ // — the contract semantic-parser's getTextContent() emits the marker on.
176
+ const icon = icons[Number(iconIndex)]
177
+ if (icon) parts.push(<Icon key={`ic${iconIndex}`} {...icon.attrs} />)
148
178
  }
179
+
149
180
  lastIdx = match.index + match[0].length
150
181
  }
151
182
  if (lastIdx < html.length) {
@@ -187,8 +218,8 @@ function renderCell(cell, block, components) {
187
218
 
188
219
  if (only?.type === 'paragraph') {
189
220
  if (!only.text) return null
190
- return /<uniweb-inset/.test(only.text) ? (
191
- renderParagraphWithInsets(only.text, block)
221
+ return hasInlineMarkers(only.text) ? (
222
+ renderParagraphWithInsets(only.text, block, only)
192
223
  ) : (
193
224
  <SafeHtml value={only.text} as="span" />
194
225
  )
@@ -229,17 +260,23 @@ export function SequenceElement({ element, block, components }) {
229
260
  const Tag = `h${level}`
230
261
  // The id is the anchor `useHeadings()` and every in-page nav link
231
262
  // resolve against, so it is behaviour rather than decoration.
263
+ // headingId() runs stripTags() first, so a marker in the text does not
264
+ // change the anchor — an icon in a heading keeps the id the TOC links to.
232
265
  return (
233
266
  <Tag id={authoredId(element) || headingId(element.text || '')}>
234
- <SafeHtml value={element.text} as="span" />
267
+ {hasInlineMarkers(element.text) ? (
268
+ renderParagraphWithInsets(element.text, block, element)
269
+ ) : (
270
+ <SafeHtml value={element.text} as="span" />
271
+ )}
235
272
  </Tag>
236
273
  )
237
274
  }
238
275
 
239
276
  case 'paragraph': {
240
277
  if (!element.text) return null
241
- if (/<uniweb-inset/.test(element.text)) {
242
- return <p>{renderParagraphWithInsets(element.text, block)}</p>
278
+ if (hasInlineMarkers(element.text)) {
279
+ return <p>{renderParagraphWithInsets(element.text, block, element)}</p>
243
280
  }
244
281
  return (
245
282
  <p>