@uniweb/core 0.8.0 → 0.8.2

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.0",
3
+ "version": "0.8.2",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@
8
8
  "./data-paths": "./src/data-paths.js",
9
9
  "./fetch-config": "./src/fetch-config.js",
10
10
  "./locale-config": "./src/locale-config.js",
11
+ "./route-match": "./src/route-match.js",
11
12
  "./section-id": "./src/section-id.js"
12
13
  },
13
14
  "files": [
@@ -34,8 +35,8 @@
34
35
  "vitest": "^4.1.7"
35
36
  },
36
37
  "dependencies": {
37
- "@uniweb/semantic-parser": "1.2.0",
38
- "@uniweb/theming": "0.1.15"
38
+ "@uniweb/theming": "0.1.15",
39
+ "@uniweb/semantic-parser": "1.2.1"
39
40
  },
40
41
  "scripts": {
41
42
  "test": "vitest run"
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Dynamic route patterns — the ONE home for how `/blog/:id` matches a path.
3
+ *
4
+ * Why this module exists. The rule was implemented twice and the two copies
5
+ * disagreed. `Website#_matchDynamicRoute` built the pattern with `:(\w+)`;
6
+ * `generate404Html` in `@uniweb/runtime`'s SSR renderer built it with
7
+ * `:[^/]+` and allowed an optional trailing slash. For `:id` they agree, so
8
+ * nothing failed — but for a param name carrying a non-word character
9
+ * (`/blog/:post-id`) the first matched only `post` and left `-id` as a
10
+ * literal, while the second consumed the whole name. Two answers to one
11
+ * question, neither wrong on the routes anyone had tried.
12
+ *
13
+ * That is already bad inside one repo. It is worse across them: a host that
14
+ * renders a page server-side has to decide *which* page a path names, and the
15
+ * runtime then hydrates over that decision in the browser. If the two matchers
16
+ * disagree by a single route, the server renders page A and hydration replaces
17
+ * it with page B — silently, and only on the paths that have a pattern, which
18
+ * are exactly the interesting ones. So this is a cross-boundary contract, not
19
+ * an implementation detail, and it is exported rather than merely shared.
20
+ *
21
+ * Zero-dependency leaf, like `./data-paths.js` and `./locale-config.js`, so a
22
+ * consumer that must not pull core's graph — an edge worker, a build step —
23
+ * can import the subpath `@uniweb/core/route-match` directly.
24
+ *
25
+ * ## The syntax, in full
26
+ *
27
+ * `:param` is the only construct. There are deliberately **no** catch-alls
28
+ * (`*`), **no** optional segments (`?`), and **no** regex constraints — a
29
+ * pattern is not a regular expression, and regex metacharacters in a route are
30
+ * escaped to literals before any substitution happens. Matching is anchored,
31
+ * case-sensitive, and a param captures exactly one non-empty path segment.
32
+ *
33
+ * ## What this module does NOT decide
34
+ *
35
+ * Matching a pattern means *the route exists*. It says nothing about whether
36
+ * the record behind it exists — that is a data question the caller answers
37
+ * later, and a matched pattern with no backing record is a rendered
38
+ * not-found page rather than a route miss. Anything deciding a 404 purely from
39
+ * this module can only answer the first question.
40
+ */
41
+
42
+ /**
43
+ * Characters allowed in a param NAME — word characters plus the hyphen, so a
44
+ * `[post-id]` route folder round-trips.
45
+ *
46
+ * Deliberately not `[^/]+`: a greedy name would swallow a literal suffix in the
47
+ * same segment, so `/files/:name.json` would capture `name.json` as the param
48
+ * name and leave nothing to match the extension.
49
+ */
50
+ const PARAM_NAME = '[A-Za-z0-9_-]+'
51
+
52
+ /** Regex metacharacters that must survive as literals. `-` is not one of them. */
53
+ const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g
54
+
55
+ /**
56
+ * Normalize a route for comparison: collapse a trailing slash, treat an empty
57
+ * route as the root.
58
+ *
59
+ * `/about/` and `/about` are the same route; `/` stays `/`.
60
+ *
61
+ * @param {string} route
62
+ * @returns {string}
63
+ */
64
+ export function normalizeRoute(route) {
65
+ if (typeof route !== 'string' || route === '') return '/'
66
+ return route === '/' ? '/' : route.replace(/\/+$/, '') || '/'
67
+ }
68
+
69
+ /**
70
+ * Whether a route is a dynamic template rather than a concrete path.
71
+ *
72
+ * @param {string} route
73
+ * @returns {boolean}
74
+ */
75
+ export function isDynamicRoute(route) {
76
+ return typeof route === 'string' && route.includes(':')
77
+ }
78
+
79
+ /**
80
+ * Compile a route pattern to an anchored regex plus its param names.
81
+ *
82
+ * Exported for callers that match one pattern against many paths and want to
83
+ * compile once — an edge worker checking every request against a site's
84
+ * patterns, for instance.
85
+ *
86
+ * @param {string} pattern - e.g. `/blog/:id`
87
+ * @returns {{ regex: RegExp, paramNames: string[] }}
88
+ */
89
+ export function routePatternToRegex(pattern) {
90
+ const paramNames = []
91
+ const source = normalizeRoute(pattern)
92
+ // Escape first: a `.` in a route is a literal `.`, not "any character".
93
+ .replace(REGEX_SPECIALS, '\\$&')
94
+ // Then each `:name` becomes one non-empty segment capture.
95
+ .replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
96
+ paramNames.push(name)
97
+ return '([^/]+)'
98
+ })
99
+
100
+ return { regex: new RegExp(`^${source}$`), paramNames }
101
+ }
102
+
103
+ /**
104
+ * Match a concrete path against a route pattern.
105
+ *
106
+ * ```js
107
+ * matchDynamicRoute('/blog/:slug', '/blog/my-post') // → { params: { slug: 'my-post' } }
108
+ * matchDynamicRoute('/blog/:slug', '/blog/a/b') // → null (a param is one segment)
109
+ * matchDynamicRoute('/blog/:slug', '/blog/') // → null (a param is non-empty)
110
+ * ```
111
+ *
112
+ * Captured values are `decodeURIComponent`-ed, so a path carries percent
113
+ * encoding and the param does not.
114
+ *
115
+ * @param {string} pattern - Route pattern with `:param` placeholders
116
+ * @param {string} path - Concrete path to match
117
+ * @returns {{ params: Record<string,string> } | null}
118
+ */
119
+ export function matchDynamicRoute(pattern, path) {
120
+ const { regex, paramNames } = routePatternToRegex(pattern)
121
+ const match = normalizeRoute(path).match(regex)
122
+ if (!match) return null
123
+
124
+ const params = {}
125
+ paramNames.forEach((name, i) => {
126
+ params[name] = decodeURIComponent(match[i + 1])
127
+ })
128
+ return { params }
129
+ }
130
+
131
+ /**
132
+ * Strip a locale prefix from a route.
133
+ *
134
+ * Pages are stored with unprefixed routes — the locale is a URL concern, not
135
+ * part of a page's identity — so a lookup has to remove it first. The default
136
+ * locale carries no prefix, which is why it is a no-op there.
137
+ *
138
+ * `/fr` and `/fr/` both mean the locale's home page.
139
+ *
140
+ * @param {string} route
141
+ * @param {string|null} activeLocale
142
+ * @param {string|null} defaultLocale
143
+ * @returns {string}
144
+ */
145
+ export function stripLocalePrefix(route, activeLocale, defaultLocale) {
146
+ if (typeof route !== 'string') return '/'
147
+ if (!activeLocale || activeLocale === defaultLocale) return route
148
+
149
+ const prefix = `/${activeLocale}`
150
+ if (route === prefix || route === `${prefix}/`) return '/'
151
+ if (route.startsWith(`${prefix}/`)) return route.slice(prefix.length)
152
+ return route
153
+ }
package/src/website.js CHANGED
@@ -11,6 +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
15
 
15
16
  /**
16
17
  * Website — orchestration root for a single site instance.
@@ -498,27 +499,10 @@ export default class Website {
498
499
  * @returns {Object|null} Match result with params, or null if no match
499
500
  */
500
501
  _matchDynamicRoute(pattern, path) {
501
- // Extract param names and build regex
502
- const paramNames = []
503
- const regexStr = pattern
504
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape special chars except :
505
- .replace(/:(\w+)/g, (_, paramName) => {
506
- paramNames.push(paramName)
507
- return '([^/]+)' // Capture anything except /
508
- })
509
-
510
- const regex = new RegExp(`^${regexStr}$`)
511
- const match = path.match(regex)
512
-
513
- if (!match) return null
514
-
515
- // Build params object
516
- const params = {}
517
- paramNames.forEach((name, i) => {
518
- params[name] = decodeURIComponent(match[i + 1])
519
- })
520
-
521
- return { params }
502
+ // Delegates to the exported matcher so a host rendering this site
503
+ // server-side can reach the identical rule — see ./route-match.js for why
504
+ // that is a contract rather than an implementation detail.
505
+ return matchDynamicRoute(pattern, path)
522
506
  }
523
507
 
524
508
  /**