@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.25",
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, makeHref, getRoutingComponents } = useWebsite()
170
+ const { website, localize, getRoutingComponents } = useWebsite()
170
171
  const RouterLink = getRoutingComponents()?.Link
171
172
 
172
173
  // Normalize href
173
- let linkHref = href || to || ''
174
+ const authoredHref = href || to || ''
174
175
 
175
- // Handle internal reference protocols
176
- // - topic: legacy internal reference
177
- // - page: stable page reference (page:pageId#sectionId)
178
- if (linkHref.startsWith('topic:') || linkHref.startsWith('page:')) {
179
- linkHref = makeHref(linkHref)
180
- }
181
-
182
- // Add locale prefix for internal links in non-default locales
183
- // Skip when reload is true — the caller provides a fully-resolved URL
184
- // (e.g., getLocaleUrl() already includes the target locale prefix)
185
- if (!reload && linkHref.startsWith('/') && !isExternalUrl(linkHref)) {
186
- if (website?.hasMultipleLocales?.()) {
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 + linkHref}
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 + linkHref}
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 internal-reference link resolution.
5
- * Handles the `page:` (stable page reference) and `topic:` (legacy) protocols
6
- * for internal content references — the same protocols kit's <Link> resolves,
4
+ * Safely renders HTML content with authored-href resolution: the `page:`
5
+ * (stable page reference) and `topic:` (legacy) protocols, and the deployment
6
+ * base path — the same resolution kit's <Link> applies to a structured link,
7
7
  * so inline link marks inside rich-text bodies resolve identically.
8
8
  *
9
+ * The resolution itself lives in utils/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: internal-reference links
67
- return website ? resolveInternalLinks(html, website) : html
47
+ // Resolve authored hrefs (page:/topic: references, base path)
48
+ return resolveProseHrefs(html, website)
68
49
  }, [value, website])
69
50
 
70
51
  // Use runtime SafeHtml if available (recommended for proper sanitization)
@@ -15,6 +15,22 @@
15
15
 
16
16
  import React, { memo } from 'react'
17
17
  import { cn } from '../../utils/index.js'
18
+ import { resolveProseHrefs } from '../../utils/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
+ }
@@ -199,43 +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
- 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
@@ -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
+ }