@uniweb/core 0.8.1 → 0.8.3
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 +4 -3
- package/src/fetcher-dispatcher.js +15 -1
- package/src/route-match.js +153 -0
- package/src/website.js +5 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
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/
|
|
38
|
-
"@uniweb/
|
|
38
|
+
"@uniweb/theming": "0.1.15",
|
|
39
|
+
"@uniweb/semantic-parser": "1.2.1"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
42
|
"test": "vitest run"
|
|
@@ -227,8 +227,22 @@ export default class FetcherDispatcher {
|
|
|
227
227
|
const cached = this._dataStore.get(key)
|
|
228
228
|
if (cached) return { data: cached.data, meta: cached.meta }
|
|
229
229
|
|
|
230
|
+
// An in-flight entry whose master has ALREADY aborted is a corpse: its
|
|
231
|
+
// underlying request is cancelled and its promise will settle to
|
|
232
|
+
// `{ data: [], error: 'aborted' }`. Attaching to it would hand this caller
|
|
233
|
+
// a cancelled result it never asked to cancel — `_attachSignal` has no way
|
|
234
|
+
// to re-arm a fired master. Treat it as a miss and start a fresh fetch.
|
|
235
|
+
//
|
|
236
|
+
// This is the sequential counterpart to the refcounting below. Refcounting
|
|
237
|
+
// covers CONCURRENT waiters (B attaches before A aborts, so the master
|
|
238
|
+
// stays live). It cannot cover SEQUENTIAL ones (A aborts, then B arrives),
|
|
239
|
+
// which is exactly React StrictMode's dev double-mount: the cleanup and the
|
|
240
|
+
// re-run happen in one synchronous commit, so B always lands in the window
|
|
241
|
+
// after the abort and before `_runFetcher`'s continuation clears the entry.
|
|
242
|
+
// Without this check the second mount inherits the first mount's abort and
|
|
243
|
+
// the block renders empty data — see tests/fetcher-dispatcher.test.js.
|
|
230
244
|
const existing = this._dataStore.inflight.get(key)
|
|
231
|
-
if (existing) {
|
|
245
|
+
if (existing && !existing.master?.signal.aborted) {
|
|
232
246
|
this._attachSignal(existing, ctx.signal)
|
|
233
247
|
return existing.promise
|
|
234
248
|
}
|
|
@@ -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
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
/**
|