@uniweb/kit 0.9.25 → 0.9.26

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.25",
3
+ "version": "0.9.26",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,6 +47,10 @@
47
47
  "react-dom": "^19.0.0"
48
48
  },
49
49
  "devDependencies": {
50
- "tailwindcss": "^4.0.0"
50
+ "tailwindcss": "^4.0.0",
51
+ "vitest": "^4.1.7"
52
+ },
53
+ "scripts": {
54
+ "test": "vitest run"
51
55
  }
52
56
  }
@@ -13,6 +13,7 @@
13
13
  import React from 'react'
14
14
  import { useWebsite } from '../../hooks/useWebsite.js'
15
15
  import { isExternalUrl, isFileUrl } from '../../utils/index.js'
16
+ import { applyBasePath } from '../../utils/prose-html.js'
16
17
 
17
18
  /**
18
19
  * Social media platforms for auto-generating link titles
@@ -214,7 +215,7 @@ export function Link({
214
215
  const basePath = !isExternal ? (website?.basePath || '') : ''
215
216
  return (
216
217
  <a
217
- href={basePath + linkHref}
218
+ href={applyBasePath(linkHref, basePath)}
218
219
  title={linkTitle}
219
220
  className={className}
220
221
  data-reload="true"
@@ -290,7 +291,7 @@ export function Link({
290
291
  const basePath = website?.basePath || ''
291
292
  return (
292
293
  <a
293
- href={basePath + linkHref}
294
+ href={applyBasePath(linkHref, basePath)}
294
295
  title={linkTitle}
295
296
  className={className}
296
297
  {...props}
@@ -1,39 +1,20 @@
1
1
  /**
2
2
  * SafeHtml Component
3
3
  *
4
- * Safely renders HTML content with internal-reference link resolution.
5
- * Handles the `page:` (stable page reference) and `topic:` (legacy) protocols
6
- * for internal content references — the same protocols kit's <Link> resolves,
4
+ * Safely renders HTML content with authored-href resolution: the `page:`
5
+ * (stable page reference) and `topic:` (legacy) protocols, and the deployment
6
+ * base path — the same resolution kit's <Link> applies to a structured link,
7
7
  * so inline link marks inside rich-text bodies resolve identically.
8
8
  *
9
+ * The resolution itself lives in utils/prose-html.js, shared with <Text>, so
10
+ * both prose renderers agree on what an authored href means.
11
+ *
9
12
  * @module @uniweb/kit/SafeHtml
10
13
  */
11
14
 
12
15
  import React, { Suspense, useMemo } from 'react'
13
16
  import { useWebsite } from '../../hooks/useWebsite.js'
14
-
15
- // Matches an <a> tag's href attribute when it carries a page:/topic: internal
16
- // reference. Regex-based (not DOMParser) so it runs identically in the browser
17
- // SPA and during SSR/prerender, where no DOM is available.
18
- const INTERNAL_HREF_RE = /(<a\b[^>]*?\shref=)(["'])((?:page|topic):[^"']*)\2/gi
19
-
20
- /**
21
- * Resolve page:/topic: internal-reference hrefs in an HTML string to real
22
- * routes via website.makeHref(). Leaves everything else untouched; an
23
- * unresolvable reference is returned by makeHref unchanged.
24
- * @param {string} html - HTML string with potential page:/topic: links
25
- * @param {Object} website - Website instance
26
- * @returns {string} HTML with resolved link hrefs
27
- */
28
- function resolveInternalLinks(html, website) {
29
- if (!html || typeof html !== 'string') return html
30
- if (!html.includes('page:') && !html.includes('topic:')) return html
31
-
32
- return html.replace(
33
- INTERNAL_HREF_RE,
34
- (_m, prefix, quote, href) => `${prefix}${quote}${website.makeHref(href)}${quote}`
35
- )
36
- }
17
+ import { resolveProseHrefs } from '../../utils/prose-html.js'
37
18
 
38
19
  /**
39
20
  * SafeHtml - Safely render HTML content
@@ -63,8 +44,8 @@ export function SafeHtml({ value, className, as: Component = 'div', ...props })
63
44
  // Handle array of HTML strings
64
45
  const html = Array.isArray(value) ? value.join('') : value
65
46
 
66
- // Resolve page:/topic: internal-reference links
67
- return website ? resolveInternalLinks(html, website) : html
47
+ // Resolve authored hrefs (page:/topic: references, base path)
48
+ return resolveProseHrefs(html, website)
68
49
  }, [value, website])
69
50
 
70
51
  // Use runtime SafeHtml if available (recommended for proper sanitization)
@@ -15,6 +15,22 @@
15
15
 
16
16
  import React, { memo } from 'react'
17
17
  import { cn } from '../../utils/index.js'
18
+ import { resolveProseHrefs } from '../../utils/prose-html.js'
19
+
20
+ /**
21
+ * Resolve authored hrefs in a prose string before it reaches the DOM.
22
+ *
23
+ * The website is read defensively rather than through useWebsite(), which
24
+ * throws when no runtime is initialized. Text is a presentation primitive and
25
+ * must stay usable without a runtime (press/unipress document builds, tests);
26
+ * making it context-required would be a regression. Same defensive read the
27
+ * runtime itself uses in Background, the SSR renderer and the link
28
+ * interceptor.
29
+ */
30
+ function resolve(text) {
31
+ if (typeof text !== 'string') return text
32
+ return resolveProseHrefs(text, globalThis.uniweb?.activeWebsite)
33
+ }
18
34
 
19
35
  /**
20
36
  * Text - Smart typography component
@@ -72,7 +88,7 @@ export const Text = memo(function Text({
72
88
  return (
73
89
  <Tag
74
90
  className={className}
75
- dangerouslySetInnerHTML={{ __html: text }}
91
+ dangerouslySetInnerHTML={{ __html: resolve(text) }}
76
92
  {...props}
77
93
  />
78
94
  )
@@ -106,7 +122,7 @@ export const Text = memo(function Text({
106
122
  return (
107
123
  <LineTag
108
124
  key={i}
109
- dangerouslySetInnerHTML={{ __html: line }}
125
+ dangerouslySetInnerHTML={{ __html: resolve(line) }}
110
126
  />
111
127
  )
112
128
  }
@@ -125,7 +141,7 @@ export const Text = memo(function Text({
125
141
  <LineTag
126
142
  key={i}
127
143
  className={className}
128
- dangerouslySetInnerHTML={{ __html: line }}
144
+ dangerouslySetInnerHTML={{ __html: resolve(line) }}
129
145
  {...props}
130
146
  />
131
147
  )
package/src/index.js CHANGED
@@ -99,6 +99,9 @@ export {
99
99
  parseIconRef,
100
100
  // Content utilities
101
101
  splitContent,
102
+ // Prose href resolution (for foundations that render prose HTML themselves)
103
+ resolveProseHref,
104
+ resolveProseHrefs,
102
105
  // Runtime utilities (getChildBlockRenderer is internal — use ChildBlocks)
103
106
  getChildBlockRenderer,
104
107
  ChildBlocks,
@@ -206,14 +206,36 @@ export function stripTags(html) {
206
206
  */
207
207
  export function isExternalUrl(url) {
208
208
  if (!url || typeof url !== 'string') return false
209
+
210
+ // Protocol-relative (//host/path) targets another authority by construction.
211
+ // Checked before the '/' test, which it would otherwise satisfy.
212
+ if (url.startsWith('//')) return true
213
+
214
+ // Site-root-relative paths and bare fragments are always internal
209
215
  if (url.startsWith('/') || url.startsWith('#')) return false
210
216
 
211
- try {
212
- const urlObj = new URL(url, window.location.origin)
213
- return urlObj.origin !== window.location.origin
214
- } catch {
215
- return false
217
+ // Anything carrying a scheme (https:, mailto:, tel:, ...) is absolute.
218
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
219
+ // In a browser we can compare origins, so a same-origin absolute URL is
220
+ // internal. Under SSR/prerender there is no origin to compare against —
221
+ // report external, which is both true in practice and the safe answer.
222
+ //
223
+ // This used to read window.location.origin unguarded. The ReferenceError
224
+ // was swallowed by the catch below, so during prerender EVERY url —
225
+ // including https://… — was reported internal, and callers that treat
226
+ // "internal" as "site-relative" then mangled it.
227
+ const origin = typeof window !== 'undefined' ? window.location?.origin : null
228
+ if (!origin) return true
229
+
230
+ try {
231
+ return new URL(url, origin).origin !== origin
232
+ } catch {
233
+ return true
234
+ }
216
235
  }
236
+
237
+ // Document-relative path (./x, x/y) — internal
238
+ return false
217
239
  }
218
240
 
219
241
  /**
@@ -243,6 +265,11 @@ export function isFileUrl(url) {
243
265
 
244
266
  export { splitContent } from './splitContent.js'
245
267
 
268
+ // Prose href resolution — exported so a foundation rendering its own prose
269
+ // HTML resolves authored hrefs the same way kit's <Text> and <SafeHtml> do,
270
+ // rather than reinventing (and diverging from) it.
271
+ export { resolveProseHref, resolveProseHrefs } from './prose-html.js'
272
+
246
273
  /**
247
274
  * Detect media type from URL
248
275
  * @param {string} url
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Prose HTML href resolution
3
+ *
4
+ * Inline formatting authored in markdown reaches components as an HTML string
5
+ * (semantic-parser bakes marks into the text — `<strong>`, `<em>`, `<a href>`).
6
+ * That string is rendered with dangerouslySetInnerHTML, so the anchors inside
7
+ * it never pass through kit's <Link> and get none of what <Link> does for a
8
+ * structured link.
9
+ *
10
+ * This module is the one place that closes that gap. An href authored in prose
11
+ * resolves exactly like the same href handed to <Link>, no matter which
12
+ * renderer draws it.
13
+ *
14
+ * WHY HERE, AND NOT IN THE PARSER
15
+ * semantic-parser is deliberately context-free — it has no website, no base
16
+ * path, no route table — and must stay that way, because @uniweb/press feeds
17
+ * the same strings into PDF, docx and typst output where a site's base path is
18
+ * meaningless. Resolution belongs at render, where the deployment context
19
+ * exists and where it can differ per target.
20
+ *
21
+ * WHY REGEX, AND NOT DOMParser
22
+ * The same code has to run in the browser and during SSR/prerender, where no
23
+ * DOM exists. A DOMParser-based resolver silently skipped resolution during
24
+ * prerender, which is the bug that motivated the regex rewrite in the first
25
+ * place.
26
+ *
27
+ * @module @uniweb/kit/utils/prose-html
28
+ */
29
+
30
+ // An <a> tag's href attribute. Captures the prefix, the quote style, and the
31
+ // value, so the replacement can preserve the original quoting.
32
+ const ANCHOR_HREF_RE = /(<a\b[^>]*?\shref=)(["'])([^"']*)\2/gi
33
+
34
+ // Schemes and shapes that are never site-relative and must be left untouched.
35
+ const NON_ROUTE_HREF_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i
36
+
37
+ /**
38
+ * Prefix a site-root-relative href with the deployment base path.
39
+ *
40
+ * Mirrors what <Link> ends up doing for a structured link: React Router's
41
+ * basename supplies it during SPA navigation, and Link's SSG fallback and
42
+ * `reload` path prepend `website.basePath` explicitly.
43
+ *
44
+ * The invariant this encodes — a base is only ever joined to a path that
45
+ * starts at the site root — is the whole point of routing every caller
46
+ * through here. A bare `basePath + href` concatenation produces garbage the
47
+ * moment href turns out to be absolute (`/basehttps://example.com/x`), and
48
+ * whether it is absolute depends on a classification that has been wrong
49
+ * before. Guarding at the join makes the failure impossible rather than
50
+ * unlikely.
51
+ *
52
+ * @param {string} href - Href to prefix
53
+ * @param {string} basePath - Deployment base (no trailing slash), '' for root
54
+ * @returns {string} Href with the base applied, or unchanged if not applicable
55
+ */
56
+ export function applyBasePath(href, basePath) {
57
+ if (!href || typeof href !== 'string' || !basePath) return href
58
+ if (!href.startsWith('/') || href.startsWith('//')) return href
59
+ if (href === basePath || href.startsWith(basePath + '/')) return href // already based
60
+ return basePath + href
61
+ }
62
+
63
+ /**
64
+ * Resolve a single authored href to the one that should reach the DOM.
65
+ *
66
+ * Order matters: an internal reference resolves to a route first, and the base
67
+ * path is applied to that route afterwards. A reference that cannot be
68
+ * resolved is returned by makeHref unchanged, still carrying its `page:`
69
+ * scheme — which the base step then correctly declines to touch.
70
+ *
71
+ * @param {string} href - Authored href
72
+ * @param {Object} website - Website instance
73
+ * @returns {string} Resolved href
74
+ */
75
+ export function resolveProseHref(href, website) {
76
+ if (!href || !website) return href
77
+
78
+ let resolved = href
79
+
80
+ // page: / topic: internal references → real route
81
+ if (href.startsWith('page:') || href.startsWith('topic:')) {
82
+ resolved = website.makeHref ? website.makeHref(href) : href
83
+ }
84
+
85
+ // Anything still carrying a scheme, protocol-relative, or a bare fragment is
86
+ // not a site route — leave it alone.
87
+ if (NON_ROUTE_HREF_RE.test(resolved)) return resolved
88
+
89
+ return applyBasePath(resolved, website.basePath || '')
90
+ }
91
+
92
+ /**
93
+ * Resolve every anchor href inside a prose HTML string.
94
+ *
95
+ * @param {string} html - HTML string from semantic-parser
96
+ * @param {Object} website - Website instance (falsy → returns html unchanged)
97
+ * @returns {string} HTML with resolved hrefs
98
+ */
99
+ export function resolveProseHrefs(html, website) {
100
+ if (!html || typeof html !== 'string' || !website) return html
101
+ if (!html.includes('<a')) return html
102
+
103
+ // Nothing to do when there are no internal references AND no base path to
104
+ // apply — the overwhelmingly common case for a site deployed at the root.
105
+ const hasRef = html.includes('page:') || html.includes('topic:')
106
+ if (!hasRef && !website.basePath) return html
107
+
108
+ return html.replace(ANCHOR_HREF_RE, (match, prefix, quote, href) => {
109
+ const resolved = resolveProseHref(href, website)
110
+ return resolved === href ? match : `${prefix}${quote}${resolved}${quote}`
111
+ })
112
+ }