@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.26",
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.21",
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/prose-html.js'
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, makeHref, getRoutingComponents } = useWebsite()
170
+ const { website, localize, getRoutingComponents } = useWebsite()
171
171
  const RouterLink = getRoutingComponents()?.Link
172
172
 
173
173
  // Normalize href
174
- let linkHref = href || to || ''
174
+ const authoredHref = href || to || ''
175
175
 
176
- // Handle internal reference protocols
177
- // - topic: legacy internal reference
178
- // - page: stable page reference (page:pageId#sectionId)
179
- if (linkHref.startsWith('topic:') || linkHref.startsWith('page:')) {
180
- linkHref = makeHref(linkHref)
181
- }
182
-
183
- // Add locale prefix for internal links in non-default locales
184
- // Skip when reload is true — the caller provides a fully-resolved URL
185
- // (e.g., getLocaleUrl() already includes the target locale prefix)
186
- if (!reload && linkHref.startsWith('/') && !isExternalUrl(linkHref)) {
187
- if (website?.hasMultipleLocales?.()) {
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/prose-html.js, shared with <Text>, so
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/prose-html.js'
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/prose-html.js'
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
- // Prose href resolution (for foundations that render prose HTML themselves)
103
- resolveProseHref,
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
+ }
@@ -199,65 +199,9 @@ export function stripTags(html) {
199
199
  return html.replace(/<[^>]*>/g, '')
200
200
  }
201
201
 
202
- /**
203
- * Check if a URL is external (different origin)
204
- * @param {string} url
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 { resolveProseHref, resolveProseHrefs } from './prose-html.js'
215
+ export { applyBasePath, resolveRoute, resolveHref, resolveProseHrefs } from './href.js'
272
216
 
273
217
  /**
274
218
  * Detect media type from URL
@@ -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
+ }
@@ -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
- }