@uniweb/kit 1.1.0 → 1.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,11 +43,11 @@
43
43
  "shiki": "^3.0.0",
44
44
  "tailwind-merge": "^3.6.0",
45
45
  "temml": "^0.13.2",
46
- "@uniweb/core": "~1.1.0",
47
- "@uniweb/projections": "~1.1.0",
48
- "@uniweb/semantic-parser": "~1.5.1",
46
+ "@uniweb/core": "~1.2.0",
47
+ "@uniweb/projections": "~1.1.1",
48
+ "@uniweb/semantic-parser": "~1.6.0",
49
49
  "@uniweb/scene": "~1.0.1",
50
- "@uniweb/schemas": "~1.0.1"
50
+ "@uniweb/schemas": "~1.1.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^19.0.0",
@@ -12,6 +12,7 @@
12
12
 
13
13
  import React, { useState, useEffect, useMemo } from 'react'
14
14
  import { getUniweb } from '@uniweb/core'
15
+ import { sanitizeSVG } from '@uniweb/semantic-parser/sanitize'
15
16
  import { cn, parseIconRef } from '../../utils/index.js'
16
17
 
17
18
  /**
@@ -93,9 +94,13 @@ function parseSvg(svgContent) {
93
94
  const strokeLinecap = getAttr(svgTag, 'stroke-linecap')
94
95
  const strokeLinejoin = getAttr(svgTag, 'stroke-linejoin')
95
96
 
96
- // Get inner content (everything between <svg> and </svg>)
97
+ // Get inner content (everything between <svg> and </svg>), rebuilt from
98
+ // the SVG allowlist before it is injected: an icon can come from content
99
+ // (an author's file, a pasted SVG) or from any URL, and markup from there
100
+ // may not run script. Built-in icons are constants and skip this.
101
+ // ⛔ Until 2026-10-14 the inner markup was injected as fetched.
97
102
  const innerMatch = svgContent.match(/<svg\s[^>]*>([\s\S]*)<\/svg>/i)
98
- const content = innerMatch ? innerMatch[1] : ''
103
+ const content = innerMatch ? sanitizeSVG(innerMatch[1]) : ''
99
104
 
100
105
  return {
101
106
  viewBox, content, width, height,
@@ -9,14 +9,20 @@
9
9
  * The resolution itself lives in utils/href.js, shared with <Text>, so both
10
10
  * prose renderers agree on what an authored href means.
11
11
  *
12
- * WHAT "SAFE" DOES NOT MEAN
13
- * This component does NOT sanitize. Nothing in the framework does. The value
14
- * is rendered as-is, so it must come from a source you trust — which the
15
- * framework's own content is: the author of a site's markdown also writes its
16
- * foundation, so there is no privilege boundary to defend. A foundation that
17
- * renders genuinely untrusted HTML (say a user-submitted string arriving
18
- * through a fetcher) is responsible for that decision, and should not read
19
- * this component's name as a guarantee.
12
+ * WHAT MAKES IT SAFE — and what does not
13
+ * The value is rendered as HTML, as given; this component cleans nothing. A
14
+ * semantic-parser string is safe to give it because the parser builds it from
15
+ * structure and escapes every character of the text and every attribute value,
16
+ * and the only markup it lets through, a math node's MathML, it rebuilds from
17
+ * an allowlist (`@uniweb/semantic-parser`, since 2026-10-14).
18
+ *
19
+ * ⛔ So a string from anywhere else does not belong here — a field a member
20
+ * wrote, a response from an API. Render it as text. *(This said until
21
+ * 2026-10-14 that "nothing in the framework" sanitizes and that a site's
22
+ * author also writes its foundation, so there was no privilege boundary — a
23
+ * premise that fails wherever the person writing content is not the one
24
+ * writing code: an author using an editor, a member of a site with a
25
+ * backend.)*
20
26
  *
21
27
  * @module @uniweb/kit/SafeHtml
22
28
  */
@@ -5,10 +5,15 @@
5
5
  * Handles single strings or arrays of paragraphs with automatic semantic
6
6
  * tag selection and empty content filtering.
7
7
  *
8
- * Security Model:
9
- * - Assumes content is ALREADY SANITIZED at the engine level
10
- * - Does NOT sanitize HTML (that's the engine's responsibility)
11
- * - Trusts the data it receives and renders it as-is
8
+ * What it renders as HTML (`html`, true by default): a semantic-parser string.
9
+ * The parser builds it from structure and escapes every character of the text
10
+ * and every attribute value, so a `content.title` or a paragraph is safe to
11
+ * inject (`@uniweb/semantic-parser`, since 2026-10-14). Text renders it as
12
+ * given and adds no cleaning of its own.
13
+ *
14
+ * ⛔ A string from anywhere else is not parser output: a field a member wrote,
15
+ * a value an API returned. Render it as text — `html={false}` (`<PlainText>`),
16
+ * or `{value}` in JSX — or a member's `<img onerror>` runs as the viewer.
12
17
  *
13
18
  * @module @uniweb/kit/Text
14
19
  */
@@ -360,8 +360,9 @@ export function SequenceElement({ element, block, components }) {
360
360
 
361
361
  case 'math': {
362
362
  // MathML is pre-compiled at parse time; the browser renders it natively,
363
- // so no runtime math library ships. Missing here until 2026-07-30, which
364
- // is why math vanished from every <Article>.
363
+ // so no runtime math library ships. semantic-parser rebuilds it from the
364
+ // MathML allowlist before it gets here. Missing here until 2026-07-30,
365
+ // which is why math vanished from every <Article>.
365
366
  if (!element.mathml) return null
366
367
  const display = element.display !== false
367
368
  const Tag = display ? 'div' : 'span'
@@ -1,4 +1,5 @@
1
1
  import { Scene as SceneRenderer, composeScene } from '@uniweb/scene'
2
+ import { sanitizeSVG } from '@uniweb/semantic-parser/sanitize'
2
3
 
3
4
  // The @uniweb/scene renderer reads `--vc-*` custom properties for its default
4
5
  // colors (an explicit value in the scene JSON always wins). Text, font, and
@@ -22,14 +23,20 @@ const THEME_VARS = {
22
23
  * Maps the site's `--primary` / `--accent` tokens onto the renderer's decorative
23
24
  * defaults so an unstyled scene adopts the theme; text and fonts inherit the
24
25
  * section context automatically. All other props (`mobileBreakpoint`,
25
- * `initialWidth`, `sanitizeSvg`, `className`, `style`, and the editing hooks)
26
- * forward straight through.
26
+ * `initialWidth`, `className`, `style`, and the editing hooks) forward straight
27
+ * through.
28
+ *
29
+ * An svg layer's markup is injected, so it is rebuilt from the SVG allowlist
30
+ * first (`sanitizeSVG`, `@uniweb/semantic-parser/sanitize`) — a scene is content,
31
+ * and whoever composed it may not be the person who wrote the foundation. Pass
32
+ * your own `sanitizeSvg` to replace it. ⛔ Until 2026-10-14 nothing was passed by
33
+ * default, so a layer's `<script>` or `onload` ran on the page.
27
34
  *
28
35
  * @example
29
36
  * <Scene composition={content.data.scene} />
30
37
  * <Scene composition={template} overrides={{ title: { content: content.title } }} />
31
38
  */
32
- export function Scene({ composition, overrides, content, style, ...rest }) {
39
+ export function Scene({ composition, overrides, content, style, sanitizeSvg = sanitizeSVG, ...rest }) {
33
40
  const data =
34
41
  overrides || content
35
42
  ? composeScene(composition, { overrides, content })
@@ -39,6 +46,7 @@ export function Scene({ composition, overrides, content, style, ...rest }) {
39
46
  <SceneRenderer
40
47
  composition={data}
41
48
  style={{ ...THEME_VARS, ...style }}
49
+ sanitizeSvg={sanitizeSvg}
42
50
  {...rest}
43
51
  />
44
52
  )
@@ -146,8 +146,15 @@ export function cn(...inputs) {
146
146
  return twMerge(clsx(inputs))
147
147
  }
148
148
 
149
+ const REFERENCES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' }
150
+
149
151
  /**
150
- * Strip HTML tags from a string
152
+ * Strip HTML tags from a string — the plain text of a parser string.
153
+ *
154
+ * semantic-parser escapes the text it puts into its strings (`a<b` arrives as
155
+ * `a&lt;b`), so the plain text decodes the references as well as dropping the
156
+ * tags, in the browser and on the server alike.
157
+ *
151
158
  * @param {string} html - HTML string
152
159
  * @returns {string} Plain text
153
160
  */
@@ -160,8 +167,15 @@ export function stripTags(html) {
160
167
  return doc.body.textContent || ''
161
168
  }
162
169
 
163
- // Fallback: simple regex (less accurate but works in Node)
164
- return html.replace(/<[^>]*>/g, '')
170
+ // Fallback (no DOM — the server): drop the tags, then decode the references
171
+ // a serializer writes. ⛔ Until 2026-10-14 it only dropped the tags.
172
+ return html
173
+ .replace(/<[^>]*>/g, '')
174
+ .replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, ref) => {
175
+ if (ref[0] !== '#') return REFERENCES[ref] ?? match
176
+ const code = ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10)
177
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
178
+ })
165
179
  }
166
180
 
167
181
  // URL predicates live in url.js so href.js can use them without importing