@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.
|
|
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
|
|
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
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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:
|
|
67
|
-
return
|
|
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,
|
package/src/utils/index.js
CHANGED
|
@@ -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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|