@uniweb/core 0.8.4 → 0.8.6

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/core",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,8 +35,8 @@
35
35
  "vitest": "^4.1.7"
36
36
  },
37
37
  "dependencies": {
38
- "@uniweb/theming": "^0.1.15",
39
- "@uniweb/semantic-parser": "^1.2.1"
38
+ "@uniweb/semantic-parser": "^1.2.2",
39
+ "@uniweb/theming": "^0.1.15"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "vitest run"
@@ -100,6 +100,35 @@ export function routePatternToRegex(pattern) {
100
100
  return { regex: new RegExp(`^${source}$`), paramNames }
101
101
  }
102
102
 
103
+ /**
104
+ * Decode a value that arrived from a URL, falling back to the raw input.
105
+ *
106
+ * Guarded rather than bare, for two independent reasons:
107
+ *
108
+ * A `%` that is not an escape is legitimate content — `/100%-Guide` authored by
109
+ * hand, or a value that has already been decoded once — and `decodeURIComponent`
110
+ * throws `URIError` on those. Falling back to the input keeps such a route
111
+ * matching exactly as well as it did before.
112
+ *
113
+ * And the input is attacker-controlled: `/blog/%zz` is a URL anyone can paste or
114
+ * link. This module is called by hosts that resolve a path to a page *per
115
+ * request*, where a throw out of the matcher is a visitor-triggerable 500 rather
116
+ * than a client-side error. A malformed escape is not a reason to lose an
117
+ * otherwise-good match, so the fallback is the raw capture rather than a miss —
118
+ * a route miss would turn a typo'd escape into a 404 on a page that exists.
119
+ *
120
+ * @param {string} value
121
+ * @returns {string}
122
+ */
123
+ export function decodeRouteValue(value) {
124
+ if (typeof value !== 'string' || !value.includes('%')) return value
125
+ try {
126
+ return decodeURIComponent(value)
127
+ } catch {
128
+ return value
129
+ }
130
+ }
131
+
103
132
  /**
104
133
  * Match a concrete path against a route pattern.
105
134
  *
@@ -109,8 +138,9 @@ export function routePatternToRegex(pattern) {
109
138
  * matchDynamicRoute('/blog/:slug', '/blog/') // → null (a param is non-empty)
110
139
  * ```
111
140
  *
112
- * Captured values are `decodeURIComponent`-ed, so a path carries percent
113
- * encoding and the param does not.
141
+ * Captured values are decoded, so a path carries percent encoding and the param
142
+ * does not. A malformed escape falls back to the raw capture rather than
143
+ * throwing — see `decodeRouteValue`. This function does not throw.
114
144
  *
115
145
  * @param {string} pattern - Route pattern with `:param` placeholders
116
146
  * @param {string} path - Concrete path to match
@@ -123,7 +153,7 @@ export function matchDynamicRoute(pattern, path) {
123
153
 
124
154
  const params = {}
125
155
  paramNames.forEach((name, i) => {
126
- params[name] = decodeURIComponent(match[i + 1])
156
+ params[name] = decodeRouteValue(match[i + 1])
127
157
  })
128
158
  return { params }
129
159
  }
package/src/website.js CHANGED
@@ -11,7 +11,7 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale } from './locale-config.js'
14
- import { matchDynamicRoute } from './route-match.js'
14
+ import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
15
15
 
16
16
  /**
17
17
  * Website — orchestration root for a single site instance.
@@ -321,16 +321,32 @@ export default class Website {
321
321
  if (!locale || locale === this.siteDefaultLocale) return displayRoute
322
322
  const entry = this._routeTranslations[locale]
323
323
  if (!entry) return displayRoute
324
+
325
+ // The caller hands us a route that came from a URL, and a browser
326
+ // percent-encodes everything outside the unreserved set — so a French slug
327
+ // arrives as `/Sites-Web/Th%C3%A8me-du-site-Web`. The translation map is
328
+ // built from site.yml, where it is authored as plain text. Decoding here
329
+ // rather than at each call site is deliberate: getPage(), normalizeRoute()
330
+ // and getLocaleUrl() all feed this, and a future caller would have to
331
+ // remember otherwise.
332
+ //
333
+ // Without it translateRoute() emits a URL this method cannot read back —
334
+ // every translated route carrying a non-ASCII character or an apostrophe
335
+ // resolved to nothing and rendered the 404 page, while the SAME route with
336
+ // an all-ASCII slug worked. The helper is shared with the captured-param
337
+ // decode in `route-match.js`, which needs the identical guard.
338
+ const route = decodeRouteValue(displayRoute)
339
+
324
340
  // Exact match
325
- const canonical = entry.reverse.get(displayRoute)
341
+ const canonical = entry.reverse.get(route)
326
342
  if (canonical) return canonical
327
343
  // Prefix match
328
344
  for (const [trans, canon] of entry.reverse) {
329
- if (displayRoute.startsWith(trans + '/')) {
330
- return canon + displayRoute.slice(trans.length)
345
+ if (route.startsWith(trans + '/')) {
346
+ return canon + route.slice(trans.length)
331
347
  }
332
348
  }
333
- return displayRoute
349
+ return route
334
350
  }
335
351
 
336
352
  /**
@@ -412,7 +428,10 @@ export default class Website {
412
428
  // Strip locale prefix if present (e.g., '/fr/about' → '/about')
413
429
  // Pages are stored with non-prefixed routes; the locale is a URL concern,
414
430
  // not a page identity concern.
415
- let stripped = route
431
+ // Decode before ANY comparison: a published payload can hold translated
432
+ // display routes verbatim, so the direct match below needs the same plain
433
+ // text form the reverse-translate path does.
434
+ let stripped = decodeRouteValue(route)
416
435
  if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
417
436
  const prefix = `/${this.activeLocale}`
418
437
  if (stripped === prefix || stripped === `${prefix}/`) {
@@ -440,10 +459,17 @@ export default class Website {
440
459
  }
441
460
 
442
461
  // Reverse-translate display route to canonical (e.g., '/acerca-de' → '/about')
443
- stripped = this.reverseTranslateRoute(stripped)
444
-
445
- // Normalize trailing slashes for consistent matching
446
- // '/about/' and '/about' should match the same page
462
+ //
463
+ // Feed it the TRAILING-SLASH-NORMALIZED form. The translation map is keyed
464
+ // without a trailing slash, so `/acerca-de/` missed the exact lookup and
465
+ // fell through to the prefix branch, which rewrites only the FIRST segment
466
+ // — `/blogue/mi-articulo/` became `/blog/mi-articulo/`, leaving the child
467
+ // segment untranslated and pointing at no page. It looked like it worked
468
+ // for as long as every child slug happened to be identical in both locales.
469
+ stripped = this.reverseTranslateRoute(normalizedStripped)
470
+
471
+ // A translation VALUE may itself carry a trailing slash, so normalize again
472
+ // rather than assuming the input normalization covered it.
447
473
  const normalizedRoute = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
448
474
 
449
475
  // Priority 1b: Exact match on canonical route