@uniweb/core 0.8.4 → 0.8.5

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/website.js +56 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@uniweb/theming": "^0.1.15",
39
- "@uniweb/semantic-parser": "^1.2.1"
39
+ "@uniweb/semantic-parser": "^1.2.2"
40
40
  },
41
41
  "scripts": {
42
42
  "test": "vitest run"
package/src/website.js CHANGED
@@ -13,6 +13,27 @@ import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale } from './locale-config.js'
14
14
  import { matchDynamicRoute } from './route-match.js'
15
15
 
16
+ /**
17
+ * Decode a route that arrived from a URL.
18
+ *
19
+ * `location.pathname` is percent-encoded per RFC 3986, while page routes and
20
+ * `i18n.routeTranslations` are authored as plain text — so the two are only
21
+ * comparable once the incoming side is decoded.
22
+ *
23
+ * Guarded rather than bare: a route may legitimately contain a `%` that is not
24
+ * an escape (`/100%-Guide` authored by hand, or a value already decoded once),
25
+ * and `decodeURIComponent` throws `URIError` on those. Falling back to the input
26
+ * keeps a malformed route matching exactly as well as it did before.
27
+ */
28
+ function decodeRoute(route) {
29
+ if (typeof route !== 'string' || !route.includes('%')) return route
30
+ try {
31
+ return decodeURIComponent(route)
32
+ } catch {
33
+ return route
34
+ }
35
+ }
36
+
16
37
  /**
17
38
  * Website — orchestration root for a single site instance.
18
39
  *
@@ -321,16 +342,32 @@ export default class Website {
321
342
  if (!locale || locale === this.siteDefaultLocale) return displayRoute
322
343
  const entry = this._routeTranslations[locale]
323
344
  if (!entry) return displayRoute
345
+
346
+ // The caller hands us a route that came from a URL, and a browser
347
+ // percent-encodes everything outside the unreserved set — so a French slug
348
+ // arrives as `/Sites-Web/Th%C3%A8me-du-site-Web`. The translation map is
349
+ // built from site.yml, where it is authored as plain text. Decoding here
350
+ // rather than at each call site is deliberate: getPage(), normalizeRoute()
351
+ // and getLocaleUrl() all feed this, and a future caller would have to
352
+ // remember otherwise.
353
+ //
354
+ // Without it translateRoute() emits a URL this method cannot read back —
355
+ // every translated route carrying a non-ASCII character or an apostrophe
356
+ // resolved to nothing and rendered the 404 page, while the SAME route with
357
+ // an all-ASCII slug worked. `route-match.js` already decodes captured
358
+ // params for exactly this reason.
359
+ const route = decodeRoute(displayRoute)
360
+
324
361
  // Exact match
325
- const canonical = entry.reverse.get(displayRoute)
362
+ const canonical = entry.reverse.get(route)
326
363
  if (canonical) return canonical
327
364
  // Prefix match
328
365
  for (const [trans, canon] of entry.reverse) {
329
- if (displayRoute.startsWith(trans + '/')) {
330
- return canon + displayRoute.slice(trans.length)
366
+ if (route.startsWith(trans + '/')) {
367
+ return canon + route.slice(trans.length)
331
368
  }
332
369
  }
333
- return displayRoute
370
+ return route
334
371
  }
335
372
 
336
373
  /**
@@ -412,7 +449,10 @@ export default class Website {
412
449
  // Strip locale prefix if present (e.g., '/fr/about' → '/about')
413
450
  // Pages are stored with non-prefixed routes; the locale is a URL concern,
414
451
  // not a page identity concern.
415
- let stripped = route
452
+ // Decode before ANY comparison: a published payload can hold translated
453
+ // display routes verbatim, so the direct match below needs the same plain
454
+ // text form the reverse-translate path does.
455
+ let stripped = decodeRoute(route)
416
456
  if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
417
457
  const prefix = `/${this.activeLocale}`
418
458
  if (stripped === prefix || stripped === `${prefix}/`) {
@@ -440,10 +480,17 @@ export default class Website {
440
480
  }
441
481
 
442
482
  // 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
483
+ //
484
+ // Feed it the TRAILING-SLASH-NORMALIZED form. The translation map is keyed
485
+ // without a trailing slash, so `/acerca-de/` missed the exact lookup and
486
+ // fell through to the prefix branch, which rewrites only the FIRST segment
487
+ // — `/blogue/mi-articulo/` became `/blog/mi-articulo/`, leaving the child
488
+ // segment untranslated and pointing at no page. It looked like it worked
489
+ // for as long as every child slug happened to be identical in both locales.
490
+ stripped = this.reverseTranslateRoute(normalizedStripped)
491
+
492
+ // A translation VALUE may itself carry a trailing slash, so normalize again
493
+ // rather than assuming the input normalization covered it.
447
494
  const normalizedRoute = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
448
495
 
449
496
  // Priority 1b: Exact match on canonical route