@uniweb/core 0.8.5 → 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.5",
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.2"
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,28 +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'
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
- }
14
+ import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
36
15
 
37
16
  /**
38
17
  * Website — orchestration root for a single site instance.
@@ -354,9 +333,9 @@ export default class Website {
354
333
  // Without it translateRoute() emits a URL this method cannot read back —
355
334
  // every translated route carrying a non-ASCII character or an apostrophe
356
335
  // 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)
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)
360
339
 
361
340
  // Exact match
362
341
  const canonical = entry.reverse.get(route)
@@ -452,7 +431,7 @@ export default class Website {
452
431
  // Decode before ANY comparison: a published payload can hold translated
453
432
  // display routes verbatim, so the direct match below needs the same plain
454
433
  // text form the reverse-translate path does.
455
- let stripped = decodeRoute(route)
434
+ let stripped = decodeRouteValue(route)
456
435
  if (this.activeLocale && this.activeLocale !== this.defaultLocale) {
457
436
  const prefix = `/${this.activeLocale}`
458
437
  if (stripped === prefix || stripped === `${prefix}/`) {