@uniweb/kit 0.9.26 → 0.9.28
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 +2 -2
- package/src/components/Link/Link.jsx +20 -31
- package/src/components/SafeHtml/SafeHtml.jsx +2 -2
- package/src/components/Text/Text.jsx +1 -1
- package/src/index.js +4 -2
- package/src/utils/href.js +170 -0
- package/src/utils/index.js +4 -60
- package/src/utils/url.js +75 -0
- package/src/utils/prose-html.js +0 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.28",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"fuse.js": "^7.0.0",
|
|
40
40
|
"shiki": "^3.0.0",
|
|
41
41
|
"tailwind-merge": "^3.6.0",
|
|
42
|
-
"@uniweb/core": "0.7.
|
|
42
|
+
"@uniweb/core": "0.7.22",
|
|
43
43
|
"@uniweb/scene": "0.1.2"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
@@ -13,7 +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/
|
|
16
|
+
import { applyBasePath, resolveRoute } from '../../utils/href.js'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Social media platforms for auto-generating link titles
|
|
@@ -167,38 +167,24 @@ export function Link({
|
|
|
167
167
|
reload,
|
|
168
168
|
...props
|
|
169
169
|
}) {
|
|
170
|
-
const { website, localize,
|
|
170
|
+
const { website, localize, getRoutingComponents } = useWebsite()
|
|
171
171
|
const RouterLink = getRoutingComponents()?.Link
|
|
172
172
|
|
|
173
173
|
// Normalize href
|
|
174
|
-
|
|
174
|
+
const authoredHref = href || to || ''
|
|
175
175
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const activeLocale = website.getActiveLocale()
|
|
189
|
-
const defaultLocale = website.getDefaultLocale()
|
|
190
|
-
if (activeLocale && activeLocale !== defaultLocale) {
|
|
191
|
-
// Translate route slug for current locale (e.g., /about → /acerca-de)
|
|
192
|
-
if (website.translateRoute) {
|
|
193
|
-
linkHref = website.translateRoute(linkHref, activeLocale)
|
|
194
|
-
}
|
|
195
|
-
const prefix = `/${activeLocale}`
|
|
196
|
-
if (!linkHref.startsWith(`${prefix}/`) && linkHref !== prefix) {
|
|
197
|
-
linkHref = linkHref === '/' ? `${prefix}/` : `${prefix}${linkHref}`
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
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 })
|
|
202
188
|
|
|
203
189
|
// Determine if this should be a download
|
|
204
190
|
const isDownload = download || isFileUrl(linkHref)
|
|
@@ -226,11 +212,14 @@ export function Link({
|
|
|
226
212
|
)
|
|
227
213
|
}
|
|
228
214
|
|
|
229
|
-
// 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.
|
|
230
219
|
if (isDownload) {
|
|
231
220
|
return (
|
|
232
221
|
<a
|
|
233
|
-
href={linkHref}
|
|
222
|
+
href={applyBasePath(linkHref, website?.basePath || '')}
|
|
234
223
|
download
|
|
235
224
|
target="_blank"
|
|
236
225
|
rel="noopener noreferrer"
|
|
@@ -6,7 +6,7 @@
|
|
|
6
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/
|
|
9
|
+
* The resolution itself lives in utils/href.js, shared with <Text>, so
|
|
10
10
|
* both prose renderers agree on what an authored href means.
|
|
11
11
|
*
|
|
12
12
|
* @module @uniweb/kit/SafeHtml
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import React, { Suspense, useMemo } from 'react'
|
|
16
16
|
import { useWebsite } from '../../hooks/useWebsite.js'
|
|
17
|
-
import { resolveProseHrefs } from '../../utils/
|
|
17
|
+
import { resolveProseHrefs } from '../../utils/href.js'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* SafeHtml - Safely render HTML content
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import React, { memo } from 'react'
|
|
17
17
|
import { cn } from '../../utils/index.js'
|
|
18
|
-
import { resolveProseHrefs } from '../../utils/
|
|
18
|
+
import { resolveProseHrefs } from '../../utils/href.js'
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Resolve authored hrefs in a prose string before it reaches the DOM.
|
package/src/index.js
CHANGED
|
@@ -99,8 +99,10 @@ export {
|
|
|
99
99
|
parseIconRef,
|
|
100
100
|
// Content utilities
|
|
101
101
|
splitContent,
|
|
102
|
-
//
|
|
103
|
-
|
|
102
|
+
// Href resolution (for foundations rendering their own links or prose HTML)
|
|
103
|
+
applyBasePath,
|
|
104
|
+
resolveRoute,
|
|
105
|
+
resolveHref,
|
|
104
106
|
resolveProseHrefs,
|
|
105
107
|
// Runtime utilities (getChildBlockRenderer is internal — use ChildBlocks)
|
|
106
108
|
getChildBlockRenderer,
|
|
@@ -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,65 +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
|
-
|
|
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
|
|
215
|
-
if (url.startsWith('/') || url.startsWith('#')) return false
|
|
216
|
-
|
|
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
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Document-relative path (./x, x/y) — internal
|
|
238
|
-
return false
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Check if a URL points to a downloadable file
|
|
243
|
-
* @param {string} url
|
|
244
|
-
* @returns {boolean}
|
|
245
|
-
*/
|
|
246
|
-
export function isFileUrl(url) {
|
|
247
|
-
if (!url || typeof url !== 'string') return false
|
|
248
|
-
|
|
249
|
-
const fileExtensions = [
|
|
250
|
-
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
251
|
-
'.zip', '.rar', '.7z', '.tar', '.gz',
|
|
252
|
-
'.mp3', '.wav', '.ogg', '.flac',
|
|
253
|
-
'.mp4', '.avi', '.mov', '.wmv', '.webm',
|
|
254
|
-
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp',
|
|
255
|
-
'.txt', '.csv', '.json', '.xml'
|
|
256
|
-
]
|
|
257
|
-
|
|
258
|
-
const lowerUrl = url.toLowerCase()
|
|
259
|
-
return fileExtensions.some(ext => lowerUrl.includes(ext))
|
|
260
|
-
}
|
|
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'
|
|
261
205
|
|
|
262
206
|
// ─────────────────────────────────────────────────────────────────
|
|
263
207
|
// Content Utilities
|
|
@@ -268,7 +212,7 @@ export { splitContent } from './splitContent.js'
|
|
|
268
212
|
// Prose href resolution — exported so a foundation rendering its own prose
|
|
269
213
|
// HTML resolves authored hrefs the same way kit's <Text> and <SafeHtml> do,
|
|
270
214
|
// rather than reinventing (and diverging from) it.
|
|
271
|
-
export {
|
|
215
|
+
export { applyBasePath, resolveRoute, resolveHref, resolveProseHrefs } from './href.js'
|
|
272
216
|
|
|
273
217
|
/**
|
|
274
218
|
* Detect media type from 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
|
+
}
|
package/src/utils/prose-html.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
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
|
-
}
|