@uniweb/kit 0.9.25 → 0.9.27
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 +6 -2
- package/src/components/Link/Link.jsx +22 -32
- package/src/components/SafeHtml/SafeHtml.jsx +9 -28
- package/src/components/Text/Text.jsx +19 -3
- package/src/index.js +5 -0
- package/src/utils/href.js +170 -0
- package/src/utils/index.js +8 -37
- package/src/utils/url.js +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.27",
|
|
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, resolveRoute } from '../../utils/href.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Social media platforms for auto-generating link titles
|
|
@@ -166,38 +167,24 @@ export function Link({
|
|
|
166
167
|
reload,
|
|
167
168
|
...props
|
|
168
169
|
}) {
|
|
169
|
-
const { website, localize,
|
|
170
|
+
const { website, localize, getRoutingComponents } = useWebsite()
|
|
170
171
|
const RouterLink = getRoutingComponents()?.Link
|
|
171
172
|
|
|
172
173
|
// Normalize href
|
|
173
|
-
|
|
174
|
+
const authoredHref = href || to || ''
|
|
174
175
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const activeLocale = website.getActiveLocale()
|
|
188
|
-
const defaultLocale = website.getDefaultLocale()
|
|
189
|
-
if (activeLocale && activeLocale !== defaultLocale) {
|
|
190
|
-
// Translate route slug for current locale (e.g., /about → /acerca-de)
|
|
191
|
-
if (website.translateRoute) {
|
|
192
|
-
linkHref = website.translateRoute(linkHref, activeLocale)
|
|
193
|
-
}
|
|
194
|
-
const prefix = `/${activeLocale}`
|
|
195
|
-
if (!linkHref.startsWith(`${prefix}/`) && linkHref !== prefix) {
|
|
196
|
-
linkHref = linkHref === '/' ? `${prefix}/` : `${prefix}${linkHref}`
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
176
|
+
// Resolve the authored href to a route: page:/topic: internal references,
|
|
177
|
+
// then slug translation and the locale prefix. Shared with prose rendering
|
|
178
|
+
// (utils/href.js) so a link written in markdown and a link passed to <Link>
|
|
179
|
+
// mean the same thing.
|
|
180
|
+
//
|
|
181
|
+
// `reload` opts out of the locale step only — its href comes from
|
|
182
|
+
// getLocaleUrl() and already carries the TARGET locale, which re-resolving
|
|
183
|
+
// against the ACTIVE one would clobber. Internal references still resolve.
|
|
184
|
+
//
|
|
185
|
+
// The base path is applied per-branch below, not here, because a
|
|
186
|
+
// Router-rendered link gets it from the router's basename instead.
|
|
187
|
+
const linkHref = resolveRoute(authoredHref, website, { locale: !reload })
|
|
201
188
|
|
|
202
189
|
// Determine if this should be a download
|
|
203
190
|
const isDownload = download || isFileUrl(linkHref)
|
|
@@ -214,7 +201,7 @@ export function Link({
|
|
|
214
201
|
const basePath = !isExternal ? (website?.basePath || '') : ''
|
|
215
202
|
return (
|
|
216
203
|
<a
|
|
217
|
-
href={basePath
|
|
204
|
+
href={applyBasePath(linkHref, basePath)}
|
|
218
205
|
title={linkTitle}
|
|
219
206
|
className={className}
|
|
220
207
|
data-reload="true"
|
|
@@ -225,11 +212,14 @@ export function Link({
|
|
|
225
212
|
)
|
|
226
213
|
}
|
|
227
214
|
|
|
228
|
-
// File downloads
|
|
215
|
+
// File downloads. A site-relative file lives under the deployment base like
|
|
216
|
+
// everything else, so the base applies here too — it used to be omitted,
|
|
217
|
+
// which broke every download link on a subdirectory deploy. applyBasePath
|
|
218
|
+
// leaves an absolute URL alone, so an off-site download is unaffected.
|
|
229
219
|
if (isDownload) {
|
|
230
220
|
return (
|
|
231
221
|
<a
|
|
232
|
-
href={linkHref}
|
|
222
|
+
href={applyBasePath(linkHref, website?.basePath || '')}
|
|
233
223
|
download
|
|
234
224
|
target="_blank"
|
|
235
225
|
rel="noopener noreferrer"
|
|
@@ -290,7 +280,7 @@ export function Link({
|
|
|
290
280
|
const basePath = website?.basePath || ''
|
|
291
281
|
return (
|
|
292
282
|
<a
|
|
293
|
-
href={basePath
|
|
283
|
+
href={applyBasePath(linkHref, basePath)}
|
|
294
284
|
title={linkTitle}
|
|
295
285
|
className={className}
|
|
296
286
|
{...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/href.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/href.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/href.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,11 @@ export {
|
|
|
99
99
|
parseIconRef,
|
|
100
100
|
// Content utilities
|
|
101
101
|
splitContent,
|
|
102
|
+
// Href resolution (for foundations rendering their own links or prose HTML)
|
|
103
|
+
applyBasePath,
|
|
104
|
+
resolveRoute,
|
|
105
|
+
resolveHref,
|
|
106
|
+
resolveProseHrefs,
|
|
102
107
|
// Runtime utilities (getChildBlockRenderer is internal — use ChildBlocks)
|
|
103
108
|
getChildBlockRenderer,
|
|
104
109
|
ChildBlocks,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Href resolution
|
|
3
|
+
*
|
|
4
|
+
* One implementation of what an authored href means, shared by every renderer
|
|
5
|
+
* that turns one into a real link.
|
|
6
|
+
*
|
|
7
|
+
* A link reaches the DOM by one of two routes, and they used to disagree:
|
|
8
|
+
*
|
|
9
|
+
* structured — <Link to="/about">, where the component receives the href
|
|
10
|
+
* prose — an <a> baked into an HTML string by semantic-parser, rendered
|
|
11
|
+
* with dangerouslySetInnerHTML, never passing through <Link>
|
|
12
|
+
*
|
|
13
|
+
* Both must apply the same rules, in the same order:
|
|
14
|
+
*
|
|
15
|
+
* 1. page: / topic: internal references resolve to a route
|
|
16
|
+
* 2. the route is translated and locale-prefixed for the active locale
|
|
17
|
+
* 3. the deployment base path is applied
|
|
18
|
+
*
|
|
19
|
+
* Steps 1-2 are `resolveRoute`. Step 3 is `applyBasePath`, kept separate
|
|
20
|
+
* because React Router supplies the base itself through its basename — a
|
|
21
|
+
* Router-rendered link must not have it applied twice. `resolveHref` is the
|
|
22
|
+
* whole chain, for every context where a plain <a> reaches the document.
|
|
23
|
+
*
|
|
24
|
+
* WHY HERE, AND NOT IN THE PARSER
|
|
25
|
+
* semantic-parser is deliberately context-free — no website, no base path, no
|
|
26
|
+
* route table — and must stay that way, because @uniweb/press feeds the same
|
|
27
|
+
* strings into PDF, docx and typst output where a base path is meaningless.
|
|
28
|
+
* Resolution belongs at render, where the deployment context exists.
|
|
29
|
+
*
|
|
30
|
+
* WHY REGEX, AND NOT DOMParser
|
|
31
|
+
* The same code runs in the browser and during SSR/prerender, where no DOM
|
|
32
|
+
* exists. A DOMParser-based resolver silently skipped resolution during
|
|
33
|
+
* prerender — the bug that motivated the regex rewrite in the first place.
|
|
34
|
+
*
|
|
35
|
+
* @module @uniweb/kit/utils/href
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { isFileUrl } from './url.js'
|
|
39
|
+
|
|
40
|
+
// An <a> tag's href attribute. Captures the prefix, the quote style, and the
|
|
41
|
+
// value, so the replacement can preserve the original quoting.
|
|
42
|
+
const ANCHOR_HREF_RE = /(<a\b[^>]*?\shref=)(["'])([^"']*)\2/gi
|
|
43
|
+
|
|
44
|
+
// Shapes that are never a site route: any scheme (https:, mailto:, tel:, and
|
|
45
|
+
// an unresolved page:), protocol-relative, and bare fragments.
|
|
46
|
+
const NON_ROUTE_HREF_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Prefix a site-root-relative href with the deployment base path.
|
|
50
|
+
*
|
|
51
|
+
* The invariant this encodes — a base is only ever joined to a path that
|
|
52
|
+
* starts at the site root — is the whole point of routing every caller
|
|
53
|
+
* through here. A bare `basePath + href` concatenation produces garbage the
|
|
54
|
+
* moment href turns out to be absolute (`/basehttps://example.com/x`), and
|
|
55
|
+
* whether it is absolute depends on a classification that has been wrong
|
|
56
|
+
* before. Guarding at the join makes the failure impossible rather than
|
|
57
|
+
* unlikely.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} href - Href to prefix
|
|
60
|
+
* @param {string} basePath - Deployment base (no trailing slash), '' for root
|
|
61
|
+
* @returns {string} Href with the base applied, or unchanged if not applicable
|
|
62
|
+
*/
|
|
63
|
+
export function applyBasePath(href, basePath) {
|
|
64
|
+
if (!href || typeof href !== 'string' || !basePath) return href
|
|
65
|
+
if (!href.startsWith('/') || href.startsWith('//')) return href
|
|
66
|
+
if (href === basePath || href.startsWith(basePath + '/')) return href // already based
|
|
67
|
+
return basePath + href
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Translate a route slug and prefix the active locale, when the site is
|
|
72
|
+
* multilingual and the active locale is not the default one.
|
|
73
|
+
*/
|
|
74
|
+
function applyLocale(route, website) {
|
|
75
|
+
if (!website.hasMultipleLocales?.()) return route
|
|
76
|
+
|
|
77
|
+
const activeLocale = website.getActiveLocale?.()
|
|
78
|
+
const defaultLocale = website.getDefaultLocale?.()
|
|
79
|
+
if (!activeLocale || activeLocale === defaultLocale) return route
|
|
80
|
+
|
|
81
|
+
// Translate the slug for this locale (e.g. /about → /acerca-de). A route
|
|
82
|
+
// with no translation comes back unchanged.
|
|
83
|
+
const translated = website.translateRoute ? website.translateRoute(route, activeLocale) : route
|
|
84
|
+
|
|
85
|
+
const prefix = `/${activeLocale}`
|
|
86
|
+
if (translated === prefix || translated.startsWith(`${prefix}/`)) return translated
|
|
87
|
+
|
|
88
|
+
return translated === '/' ? `${prefix}/` : `${prefix}${translated}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve an authored href to a site route: internal references, then locale.
|
|
93
|
+
* Does NOT apply the base path — see the module note on React Router.
|
|
94
|
+
*
|
|
95
|
+
* The locale step deliberately skips files. Only pages are emitted per locale;
|
|
96
|
+
* everything under the site's public directory — images, fonts, PDFs — is
|
|
97
|
+
* emitted once at the root, so locale-prefixing an asset href points it at a
|
|
98
|
+
* path that does not exist.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} href - Authored href
|
|
101
|
+
* @param {Object} website - Website instance
|
|
102
|
+
* @param {Object} [options]
|
|
103
|
+
* @param {boolean} [options.locale=true] - Apply slug translation and the
|
|
104
|
+
* locale prefix. Pass false when the caller already supplies a
|
|
105
|
+
* locale-resolved URL — <Link reload>, whose href comes from getLocaleUrl()
|
|
106
|
+
* and carries the TARGET locale, which re-resolving against the ACTIVE one
|
|
107
|
+
* would clobber.
|
|
108
|
+
* @returns {string} Resolved route
|
|
109
|
+
*/
|
|
110
|
+
export function resolveRoute(href, website, { locale = true } = {}) {
|
|
111
|
+
if (!href || typeof href !== 'string' || !website) return href
|
|
112
|
+
|
|
113
|
+
let resolved = href
|
|
114
|
+
|
|
115
|
+
// 1. page: / topic: internal references → real route
|
|
116
|
+
if (href.startsWith('page:') || href.startsWith('topic:')) {
|
|
117
|
+
resolved = website.makeHref ? website.makeHref(href) : href
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Anything still carrying a scheme, protocol-relative, or a bare fragment is
|
|
121
|
+
// not a site route. An unresolvable page: reference lands here too, which is
|
|
122
|
+
// why makeHref returning it unchanged is safe.
|
|
123
|
+
if (NON_ROUTE_HREF_RE.test(resolved)) return resolved
|
|
124
|
+
|
|
125
|
+
// 2. Translate and locale-prefix — pages only
|
|
126
|
+
if (locale && resolved.startsWith('/') && !isFileUrl(resolved)) {
|
|
127
|
+
resolved = applyLocale(resolved, website)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return resolved
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve an authored href all the way to what belongs in a plain <a href>:
|
|
135
|
+
* internal references, locale, and the deployment base path.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} href - Authored href
|
|
138
|
+
* @param {Object} website - Website instance
|
|
139
|
+
* @returns {string} Fully resolved href
|
|
140
|
+
*/
|
|
141
|
+
export function resolveHref(href, website) {
|
|
142
|
+
if (!href || typeof href !== 'string' || !website) return href
|
|
143
|
+
return applyBasePath(resolveRoute(href, website), website.basePath || '')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve every anchor href inside a prose HTML string.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} html - HTML string from semantic-parser
|
|
150
|
+
* @param {Object} website - Website instance (falsy → returns html unchanged)
|
|
151
|
+
* @returns {string} HTML with resolved hrefs
|
|
152
|
+
*/
|
|
153
|
+
export function resolveProseHrefs(html, website) {
|
|
154
|
+
if (!html || typeof html !== 'string' || !website) return html
|
|
155
|
+
if (!html.includes('<a')) return html
|
|
156
|
+
|
|
157
|
+
// Nothing to do when there are no internal references, no base path and no
|
|
158
|
+
// active non-default locale — the common case for a monolingual site served
|
|
159
|
+
// at the root.
|
|
160
|
+
const hasRef = html.includes('page:') || html.includes('topic:')
|
|
161
|
+
const localized =
|
|
162
|
+
!!website.hasMultipleLocales?.() &&
|
|
163
|
+
website.getActiveLocale?.() !== website.getDefaultLocale?.()
|
|
164
|
+
if (!hasRef && !website.basePath && !localized) return html
|
|
165
|
+
|
|
166
|
+
return html.replace(ANCHOR_HREF_RE, (match, prefix, quote, href) => {
|
|
167
|
+
const resolved = resolveHref(href, website)
|
|
168
|
+
return resolved === href ? match : `${prefix}${quote}${resolved}${quote}`
|
|
169
|
+
})
|
|
170
|
+
}
|
package/src/utils/index.js
CHANGED
|
@@ -199,43 +199,9 @@ export function stripTags(html) {
|
|
|
199
199
|
return html.replace(/<[^>]*>/g, '')
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
* @returns {boolean}
|
|
206
|
-
*/
|
|
207
|
-
export function isExternalUrl(url) {
|
|
208
|
-
if (!url || typeof url !== 'string') return false
|
|
209
|
-
if (url.startsWith('/') || url.startsWith('#')) return false
|
|
210
|
-
|
|
211
|
-
try {
|
|
212
|
-
const urlObj = new URL(url, window.location.origin)
|
|
213
|
-
return urlObj.origin !== window.location.origin
|
|
214
|
-
} catch {
|
|
215
|
-
return false
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Check if a URL points to a downloadable file
|
|
221
|
-
* @param {string} url
|
|
222
|
-
* @returns {boolean}
|
|
223
|
-
*/
|
|
224
|
-
export function isFileUrl(url) {
|
|
225
|
-
if (!url || typeof url !== 'string') return false
|
|
226
|
-
|
|
227
|
-
const fileExtensions = [
|
|
228
|
-
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
229
|
-
'.zip', '.rar', '.7z', '.tar', '.gz',
|
|
230
|
-
'.mp3', '.wav', '.ogg', '.flac',
|
|
231
|
-
'.mp4', '.avi', '.mov', '.wmv', '.webm',
|
|
232
|
-
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp',
|
|
233
|
-
'.txt', '.csv', '.json', '.xml'
|
|
234
|
-
]
|
|
235
|
-
|
|
236
|
-
const lowerUrl = url.toLowerCase()
|
|
237
|
-
return fileExtensions.some(ext => lowerUrl.includes(ext))
|
|
238
|
-
}
|
|
202
|
+
// URL predicates live in url.js so href.js can use them without importing
|
|
203
|
+
// this barrel, which re-exports href.js in turn.
|
|
204
|
+
export { isExternalUrl, isFileUrl } from './url.js'
|
|
239
205
|
|
|
240
206
|
// ─────────────────────────────────────────────────────────────────
|
|
241
207
|
// Content Utilities
|
|
@@ -243,6 +209,11 @@ export function isFileUrl(url) {
|
|
|
243
209
|
|
|
244
210
|
export { splitContent } from './splitContent.js'
|
|
245
211
|
|
|
212
|
+
// Prose href resolution — exported so a foundation rendering its own prose
|
|
213
|
+
// HTML resolves authored hrefs the same way kit's <Text> and <SafeHtml> do,
|
|
214
|
+
// rather than reinventing (and diverging from) it.
|
|
215
|
+
export { applyBasePath, resolveRoute, resolveHref, resolveProseHrefs } from './href.js'
|
|
216
|
+
|
|
246
217
|
/**
|
|
247
218
|
* Detect media type from URL
|
|
248
219
|
* @param {string} url
|
package/src/utils/url.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL predicates
|
|
3
|
+
*
|
|
4
|
+
* Classification of an href, independent of any website or deployment. Kept in
|
|
5
|
+
* its own module so href.js can use it without importing the utils barrel,
|
|
6
|
+
* which re-exports href.js in turn.
|
|
7
|
+
*
|
|
8
|
+
* Both predicates must give the same answer in the browser and during
|
|
9
|
+
* SSR/prerender — the prerendered document and the hydrated DOM are meant to
|
|
10
|
+
* be the same document, and a classification that flips between them produces
|
|
11
|
+
* exactly the kind of corruption isExternalUrl once caused.
|
|
12
|
+
*
|
|
13
|
+
* @module @uniweb/kit/utils/url
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if a URL is external (different origin)
|
|
18
|
+
* @param {string} url
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isExternalUrl(url) {
|
|
22
|
+
if (!url || typeof url !== 'string') return false
|
|
23
|
+
|
|
24
|
+
// Protocol-relative (//host/path) targets another authority by construction.
|
|
25
|
+
// Checked before the '/' test, which it would otherwise satisfy.
|
|
26
|
+
if (url.startsWith('//')) return true
|
|
27
|
+
|
|
28
|
+
// Site-root-relative paths and bare fragments are always internal
|
|
29
|
+
if (url.startsWith('/') || url.startsWith('#')) return false
|
|
30
|
+
|
|
31
|
+
// Anything carrying a scheme (https:, mailto:, tel:, ...) is absolute.
|
|
32
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
|
|
33
|
+
// In a browser we can compare origins, so a same-origin absolute URL is
|
|
34
|
+
// internal. Under SSR/prerender there is no origin to compare against —
|
|
35
|
+
// report external, which is both true in practice and the safe answer.
|
|
36
|
+
//
|
|
37
|
+
// This used to read window.location.origin unguarded. The ReferenceError
|
|
38
|
+
// was swallowed by a catch, so during prerender EVERY url — including
|
|
39
|
+
// https:// ones — was reported internal, and callers that treat "internal"
|
|
40
|
+
// as "site-relative" then mangled it.
|
|
41
|
+
const origin = typeof window !== 'undefined' ? window.location?.origin : null
|
|
42
|
+
if (!origin) return true
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
return new URL(url, origin).origin !== origin
|
|
46
|
+
} catch {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Document-relative path (./x, x/y) — internal
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extensions that mark an href as a file rather than a page route.
|
|
56
|
+
const FILE_EXTENSIONS = [
|
|
57
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
58
|
+
'.zip', '.rar', '.7z', '.tar', '.gz',
|
|
59
|
+
'.mp3', '.wav', '.ogg', '.flac',
|
|
60
|
+
'.mp4', '.avi', '.mov', '.wmv', '.webm',
|
|
61
|
+
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp',
|
|
62
|
+
'.txt', '.csv', '.json', '.xml'
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a URL points to a downloadable file
|
|
67
|
+
* @param {string} url
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isFileUrl(url) {
|
|
71
|
+
if (!url || typeof url !== 'string') return false
|
|
72
|
+
|
|
73
|
+
const lowerUrl = url.toLowerCase()
|
|
74
|
+
return FILE_EXTENSIONS.some(ext => lowerUrl.includes(ext))
|
|
75
|
+
}
|