@uniweb/kit 0.11.2 → 0.12.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": "0.11.2",
3
+ "version": "0.12.0",
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",
47
+ "@uniweb/semantic-parser": "^1.2.2",
48
48
  "@uniweb/scene": "^0.1.3",
49
- "@uniweb/semantic-parser": "^1.2.1"
49
+ "@uniweb/core": "^0.8.5"
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
 
@@ -9,19 +9,27 @@ import { useWebsite } from './useWebsite.js'
9
9
  *
10
10
  * The hook resolves *where* to submit from the site's configuration (`submit:`
11
11
  * in site.yml) or from its host, so a component never names an endpoint. When
12
- * neither supplies one, `canSubmit` is false and `unavailableReason` says why
13
- * render the control disabled rather than letting someone fill in a form whose
12
+ * neither supplies one, `canSubmit` is false don't render a form whose
14
13
  * contents have nowhere to go. See `resolveSubmitTarget` for the precedence.
15
14
  *
16
15
  * ```jsx
17
- * const { submit, status, error, canSubmit, unavailableReason } =
16
+ * const { submit, status, error, canSubmit } =
18
17
  * useFormSubmit({ block, context: { formId: 'contact' } })
19
18
  *
20
- * <button type="submit" disabled={!canSubmit || status === 'submitting'}>
21
- * {canSubmit ? 'Send' : unavailableReason}
22
- * </button>
19
+ * if (!canSubmit) return null // or fall back to contact details the
20
+ * // site already carries in its content
21
+ *
22
+ * <button type="submit" disabled={status === 'submitting'}>Send</button>
23
23
  * ```
24
24
  *
25
+ * ⛔ **There is no `unavailableReason`, and there was one — it was a mistake.**
26
+ * It carried a canned English sentence and this very example rendered it as
27
+ * button copy. A visitor has no stake in which services the operator bought,
28
+ * the string reads like a breakage when nothing is broken, and it cannot be
29
+ * translated on a framework whose sites are usually multilingual and often not
30
+ * English. Text a visitor reads is *site content*. `canSubmit` is the whole
31
+ * signal.
32
+ *
25
33
  * Pass `block` and the submission carries where it came from — section type,
26
34
  * section id, page id and label — without every component assembling that by
27
35
  * hand. Anything in `context` wins over what the block supplies.
@@ -39,7 +47,6 @@ import { useWebsite } from './useWebsite.js'
39
47
  * error: Error | null,
40
48
  * response: object | null,
41
49
  * canSubmit: boolean,
42
- * unavailableReason: string | null,
43
50
  * submit: (formData: object, overrides?: object) => Promise<object>,
44
51
  * reset: () => void,
45
52
  * }}
@@ -50,7 +57,7 @@ export function useFormSubmit(defaults = {}) {
50
57
  const [response, setResponse] = useState(null)
51
58
 
52
59
  const { website } = useWebsite()
53
- const { url: target, reason: unavailableReason } = resolveSubmitTarget(website)
60
+ const { url: target } = resolveSubmitTarget(website)
54
61
 
55
62
  // `defaults` is a fresh object every render, so listing it as a dependency
56
63
  // would rebuild the callback on each one. A ref is what actually makes "the
@@ -103,7 +110,6 @@ export function useFormSubmit(defaults = {}) {
103
110
  error,
104
111
  response,
105
112
  canSubmit: !!target,
106
- unavailableReason,
107
113
  // Whether attachments can be delivered. True once there is a target: the
108
114
  // client sends the manifest, then the bytes, then finalizes.
109
115
  //
@@ -51,7 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
51
51
  * It lists the paths of `required` controls that are still empty. It does not
52
52
  * block anything: whether an incomplete form disables the button, shows a
53
53
  * message, or submits anyway is a design decision. `useFormSubmit` draws the
54
- * same line with `canSubmit` / `unavailableReason` — the kit works out the
54
+ * same line with `canSubmit` — the kit works out the
55
55
  * fact, the foundation decides what it looks like.
56
56
  *
57
57
  * Empty means `undefined`, `null`, `''`, or `[]`. A `false` boolean is a VALUE,
package/src/index.js CHANGED
@@ -130,11 +130,9 @@ export {
130
130
  submitForm,
131
131
  deriveSummary,
132
132
  resolveSubmitTarget,
133
- NO_SUBMIT_TARGET_REASON,
134
133
  // Site services (search / submit / assistant / anything a host offers)
135
134
  resolveService,
136
- resolveServiceUrl,
137
- NO_SERVICE_REASON
135
+ resolveServiceUrl
138
136
  } from './utils/index.js'
139
137
 
140
138
  // ============================================================================
@@ -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>
@@ -248,8 +248,11 @@ export function detectMediaType(url) {
248
248
  // ─────────────────────────────────────────────────────────────────
249
249
 
250
250
  export { submitForm, deriveSummary } from './submitForm.js'
251
- export { resolveSubmitTarget, NO_SUBMIT_TARGET_REASON } from './submitTarget.js'
252
- export { resolveService, resolveServiceUrl, NO_SERVICE_REASON } from './services.js'
251
+ export { resolveSubmitTarget } from './submitTarget.js'
252
+ export {
253
+ resolveService,
254
+ resolveServiceUrl
255
+ } from './services.js'
253
256
 
254
257
  /**
255
258
  * The text of a ProseMirror node, flattened.
@@ -17,8 +17,8 @@ import { applyBasePath } from './href.js'
17
17
  * 2. **The host**, served — `config.services.<name>` in the payload. What the
18
18
  * deployment offers, which the site never had to know about.
19
19
  *
20
- * Absent from both means the site has no such service, and the component says so
21
- * rather than guessing an address. That is the same rule for every service, and
20
+ * Absent from both means the site has no such service, and the component renders
21
+ * for that rather than guessing an address. That is the same rule for every service, and
22
22
  * it is why this module exists: it was previously implemented three times — the
23
23
  * search provider, the submit resolver, and a hand-rolled copy inside a
24
24
  * foundation — with three slightly different base-joining rules between them.
@@ -37,11 +37,33 @@ import { applyBasePath } from './href.js'
37
37
  *
38
38
  * ## What this deliberately does not model
39
39
  *
40
- * **Entitlement.** A host that will not serve a service either omits it or
41
- * supplies a `reason`, which is relayed to the UI verbatim. The framework never
42
- * learns why — no plan names, no tiers, no "paid" anywhere. That is not
43
- * squeamishness: `@uniweb/kit` is public, and a framework that encodes which
44
- * capabilities cost money ships the business model into open source.
40
+ * **Entitlement.** A host that will not serve a service omits it, or declares
41
+ * the name with no address. The framework never learns why no plan names, no
42
+ * tiers, no "paid" anywhere. That is not squeamishness: `@uniweb/kit` is
43
+ * public, and a framework that encodes which capabilities cost money ships the
44
+ * business model into open source.
45
+ *
46
+ * ⛔ **There is deliberately no explanatory string, and there was one — it was
47
+ * a mistake.** Until 2026-08-13 a declining host could supply a `reason` that
48
+ * this module relayed "to the UI verbatim", with an English default
49
+ * (`NO_SERVICE_REASON`) when nothing did. Removed, on two counts:
50
+ *
51
+ * 1. **Wrong audience.** A visitor has no stake in which services an operator
52
+ * provisioned. "Submissions are not enabled for this site" reports someone's
53
+ * billing state to the public and reads like a breakage. It is neither — it
54
+ * is a service that was not bought, and **a generic component is supposed to
55
+ * be smart about that.**
56
+ * 2. **Wrong language, unfixably.** Sites here are multilingual, or unilingual
57
+ * and not English. A host-supplied sentence bypasses the site's entire
58
+ * localization pipeline, and a canned constant in a public package cannot
59
+ * be translated at all. Any text a visitor should read is *site content*,
60
+ * which is authored and localized — never a string a service layer invents.
61
+ *
62
+ * ⇒ **`url` is the whole answer, and absence is a rendering decision rather
63
+ * than a message.** No submit endpoint → render no form, or degrade to
64
+ * something that still serves the visitor: a `mailto:` or a number the site
65
+ * already carries in its content. No assistant → render no Ask-AI affordance.
66
+ * Nobody is told why, because nobody visiting needs to know.
45
67
  *
46
68
  * **The site's own base.** `config.base` is where the site *lives*, not a
47
69
  * service it consumes — it is load-bearing for routing and asset URLs too. It
@@ -51,9 +73,6 @@ import { applyBasePath } from './href.js'
51
73
  /** Anything with a scheme, or protocol-relative — never joined to a base. */
52
74
  const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
53
75
 
54
- /** Shown when nothing supplies a service and the caller gives no wording. */
55
- export const NO_SERVICE_REASON = 'This site has no such service configured.'
56
-
57
76
  /**
58
77
  * Read an endpoint out of either declaration form.
59
78
  *
@@ -108,19 +127,19 @@ export function resolveServiceUrl(endpoint, basePath = '') {
108
127
  * Resolve where a named service lives for this site.
109
128
  *
110
129
  * ```js
111
- * const { url, reason } = resolveService(website, 'submit')
112
- * if (!url) return renderDisabled(reason)
130
+ * const { url } = resolveService(website, 'submit')
131
+ * if (!url) return null // no endpoint — render no form, or degrade
113
132
  * ```
114
133
  *
115
134
  * @param {object} website - the active Website
116
135
  * @param {string} name - service name, e.g. 'submit' · 'search' · 'assistant'
117
- * @param {object} [options]
118
- * @param {string} [options.reason] - wording when nothing supplies it
119
- * @returns {{ url: string|null, reason: string|null, source: 'site'|'host'|null }}
120
- * `source` says which declaration answered useful in diagnostics, and the
121
- * thing to check when a host's value appears not to be taking effect.
136
+ * @returns {{ url: string|null, source: 'site'|'host'|null }}
137
+ * `url` is the whole answer for rendering. `source` says which declaration
138
+ * answered a diagnostic, and the thing to check when a host's value appears
139
+ * not to be taking effect. `'host'` with a null `url` means the host answered
140
+ * and offered no address; `null` means nothing declared the service at all.
122
141
  */
123
- export function resolveService(website, name, options = {}) {
142
+ export function resolveService(website, name) {
124
143
  const config = website?.config
125
144
  const basePath = website?.basePath
126
145
 
@@ -128,21 +147,22 @@ export function resolveService(website, name, options = {}) {
128
147
  // means it, including on a host that offers one.
129
148
  const authored = readEndpoint(config?.[name])
130
149
  if (authored) {
131
- return { url: resolveServiceUrl(authored, basePath), reason: null, source: 'site' }
150
+ return { url: resolveServiceUrl(authored, basePath), source: 'site' }
132
151
  }
133
152
 
134
153
  // 2 — what the host says it offers.
135
154
  const hostDeclaration = config?.services?.[name]
136
155
  const hostEndpoint = readEndpoint(hostDeclaration)
137
156
  if (hostEndpoint) {
138
- return { url: resolveServiceUrl(hostEndpoint, basePath), reason: null, source: 'host' }
157
+ return { url: resolveServiceUrl(hostEndpoint, basePath), source: 'host' }
139
158
  }
140
159
 
141
- // A host that declines may say why, and that reaches the visitor unaltered.
142
- const hostReason =
143
- typeof hostDeclaration?.reason === 'string' ? hostDeclaration.reason.trim() : ''
144
- if (hostReason) return { url: null, reason: hostReason, source: 'host' }
160
+ // A host may declare the name while offering no address a decline. It is
161
+ // still the host answering, which is all a caller can use: any *wording* for
162
+ // that state would be ours to invent, in one language, for a visitor who has
163
+ // no stake in it. See the entitlement note above.
164
+ if (hostDeclaration !== undefined) return { url: null, source: 'host' }
145
165
 
146
166
  // 3 — nobody supplied one.
147
- return { url: null, reason: options.reason || NO_SERVICE_REASON, source: null }
167
+ return { url: null, source: null }
148
168
  }
@@ -31,24 +31,16 @@ import { resolveService, resolveServiceUrl } from './services.js'
31
31
  * only have come from its host.
32
32
  */
33
33
 
34
- /**
35
- * Shown when a site declares no endpoint and no host offers one. English, and a
36
- * foundation is free to ignore it and render its own copy — check `url` for the
37
- * yes/no and treat this as a default rather than a string to translate.
38
- */
39
- export const NO_SUBMIT_TARGET_REASON =
40
- 'This site has no form submission endpoint configured.'
41
-
42
34
  /**
43
35
  * Resolve the submission target for a site.
44
36
  *
45
37
  * @param {object} website - The active Website instance
46
- * @returns {{ url: string|null, reason: string|null, source: 'site'|'host'|null }}
47
- * `url` is the resolved endpoint, or null when nothing supplies one. `reason`
48
- * is set exactly when `url` is null.
38
+ * @returns {{ url: string|null, source: 'site'|'host'|null }}
39
+ * `url` is the resolved endpoint, or null when nothing supplies one — in which
40
+ * case render no form, or degrade to contact details the site already carries.
49
41
  */
50
42
  export function resolveSubmitTarget(website) {
51
- return resolveService(website, 'submit', { reason: NO_SUBMIT_TARGET_REASON })
43
+ return resolveService(website, 'submit')
52
44
  }
53
45
 
54
46
  /**