@uniweb/core 0.25.1 → 0.26.1
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 +1 -1
- package/src/entity-store.js +5 -5
- package/src/index.js +4 -0
- package/src/route-match.js +135 -3
- package/src/website.js +2 -2
package/package.json
CHANGED
package/src/entity-store.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { isFetchRefinement, resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
|
|
18
|
-
import { fillRoutePattern,
|
|
18
|
+
import { fillRoutePattern, matchesRouteParam } from './route-match.js'
|
|
19
19
|
import { sortRecords } from './sort.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -288,7 +288,7 @@ export default class EntityStore {
|
|
|
288
288
|
const { paramName, paramValue } = dynamicContext
|
|
289
289
|
const items = cached.data
|
|
290
290
|
let filtered = Array.isArray(items)
|
|
291
|
-
? items.filter((item) =>
|
|
291
|
+
? items.filter((item) => !matchesRouteParam(item, paramName, paramValue))
|
|
292
292
|
: items
|
|
293
293
|
if (order) filtered = this._sortItems(filtered, order)
|
|
294
294
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
@@ -314,7 +314,7 @@ export default class EntityStore {
|
|
|
314
314
|
const { paramName, paramValue } = dynamicContext
|
|
315
315
|
const items = cached.data
|
|
316
316
|
const match = Array.isArray(items)
|
|
317
|
-
? items.find((item) =>
|
|
317
|
+
? items.find((item) => matchesRouteParam(item, paramName, paramValue))
|
|
318
318
|
: null
|
|
319
319
|
if (!match) {
|
|
320
320
|
data[schema] = []
|
|
@@ -422,7 +422,7 @@ export default class EntityStore {
|
|
|
422
422
|
}
|
|
423
423
|
const { paramName, paramValue } = dynamicContext
|
|
424
424
|
let filtered = Array.isArray(records)
|
|
425
|
-
? records.filter((item) =>
|
|
425
|
+
? records.filter((item) => !matchesRouteParam(item, paramName, paramValue))
|
|
426
426
|
: (records ?? [])
|
|
427
427
|
if (order) filtered = this._sortItems(filtered, order)
|
|
428
428
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
@@ -460,7 +460,7 @@ export default class EntityStore {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
const match = records?.find(
|
|
463
|
-
(item) =>
|
|
463
|
+
(item) => matchesRouteParam(item, paramName, paramValue)
|
|
464
464
|
) ?? null
|
|
465
465
|
|
|
466
466
|
if (!match) {
|
package/src/index.js
CHANGED
|
@@ -64,10 +64,14 @@ export {
|
|
|
64
64
|
joinPathCapture,
|
|
65
65
|
recordHandle,
|
|
66
66
|
routeParamValue,
|
|
67
|
+
routeParamValues,
|
|
68
|
+
matchesRouteParam,
|
|
67
69
|
routeRecordKey,
|
|
68
70
|
routeBinding,
|
|
69
71
|
routeParamName,
|
|
70
72
|
parentRouteOf,
|
|
73
|
+
recordRouteBase,
|
|
74
|
+
findPageForRoute,
|
|
71
75
|
} from './route-match.js'
|
|
72
76
|
export { isRichSchema } from './schemas.js'
|
|
73
77
|
// ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
|
package/src/route-match.js
CHANGED
|
@@ -95,6 +95,8 @@ export function isDynamicRoute(route) {
|
|
|
95
95
|
|
|
96
96
|
/** The catch-all token, only as a pattern's final segment: `/:path*`. */
|
|
97
97
|
const CATCH_ALL = new RegExp(`/:(${PARAM_NAME})\\*$`)
|
|
98
|
+
/** A route whose LAST segment is a parameter — the page that addresses one record. */
|
|
99
|
+
const RECORD_ROUTE = new RegExp(`/:(${PARAM_NAME})\\*?$`)
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
102
|
* Compile a route pattern to an anchored regex plus its param names.
|
|
@@ -305,6 +307,58 @@ export function routeParamValue(record, paramName) {
|
|
|
305
307
|
return record[paramName]
|
|
306
308
|
}
|
|
307
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Every value a record offers for a route's param, as strings — one for a scalar,
|
|
312
|
+
* one per member for a `multi` field.
|
|
313
|
+
*
|
|
314
|
+
* ⭐ A `multi` FIELD MATCHES MEMBER-WISE (ruled 2026-09-12 [Diego]: *"if the code is
|
|
315
|
+
* natural for backend, I think it can be useful to match member-wise"*), and the
|
|
316
|
+
* case it is for is not tag pages: it is a Model field TYPED `multi` that holds one
|
|
317
|
+
* value — `department: ['biology']` — which an author routes as `[department]` and
|
|
318
|
+
* thinks of as a scalar. Matched whole, that page silently renders not-found.
|
|
319
|
+
*
|
|
320
|
+
* ⛔ A ROUTE FIELD THAT IS NOT UNIQUE MAKES TIES NORMAL. Two records holding `'a'`
|
|
321
|
+
* both claim `/tags/a`; the first wins, and *which* is first is this lane's order —
|
|
322
|
+
* the build's record order here, the service's storage order on a hosted site. They
|
|
323
|
+
* can differ, with no error on either. The static build warns when two records claim
|
|
324
|
+
* one route; `docs/reference/dynamic-routes.md` says it plainly.
|
|
325
|
+
*
|
|
326
|
+
* Empty and duplicate members drop, so a record can never claim `/tags/` or the same
|
|
327
|
+
* route twice. ⚠️ `undefined` drops too — it used to stringify to `'undefined'` and
|
|
328
|
+
* match a URL segment spelled that way.
|
|
329
|
+
*
|
|
330
|
+
* @param {Object} record
|
|
331
|
+
* @param {string} paramName
|
|
332
|
+
* @returns {string[]} the values, in the record's own order
|
|
333
|
+
*/
|
|
334
|
+
export function routeParamValues(record, paramName) {
|
|
335
|
+
const raw = routeParamValue(record, paramName)
|
|
336
|
+
const out = []
|
|
337
|
+
for (const value of Array.isArray(raw) ? raw : [raw]) {
|
|
338
|
+
if (value === undefined || value === null || value === '') continue
|
|
339
|
+
const text = String(value)
|
|
340
|
+
if (text === '' || out.includes(text)) continue
|
|
341
|
+
out.push(text)
|
|
342
|
+
}
|
|
343
|
+
return out
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Does this record answer to this value for the route's param? The one comparison
|
|
348
|
+
* every lane makes — the SPA, the prefetch, the static build — so a URL that finds a
|
|
349
|
+
* record in one cannot miss it in another. Compared as STRINGS: a URL segment is
|
|
350
|
+
* text, so `'42'` matches a field holding `42`.
|
|
351
|
+
*
|
|
352
|
+
* @param {Object} record
|
|
353
|
+
* @param {string} paramName
|
|
354
|
+
* @param {string|number} value - the URL segment
|
|
355
|
+
* @returns {boolean}
|
|
356
|
+
*/
|
|
357
|
+
export function matchesRouteParam(record, paramName, value) {
|
|
358
|
+
const target = String(value)
|
|
359
|
+
return routeParamValues(record, paramName).some((held) => held === target)
|
|
360
|
+
}
|
|
361
|
+
|
|
308
362
|
/**
|
|
309
363
|
* A parametric page's route binding — the param the record is matched on, its
|
|
310
364
|
* value, and the route VARIABLES a query may reference. ONE implementation, for
|
|
@@ -391,6 +445,35 @@ export function parentRouteOf(route, { declared = null, has } = {}) {
|
|
|
391
445
|
return has(up) ? up : null
|
|
392
446
|
}
|
|
393
447
|
|
|
448
|
+
/**
|
|
449
|
+
* The base route a record's URL composes onto, for a page that ADDRESSES ONE RECORD —
|
|
450
|
+
* or null when the page is not one.
|
|
451
|
+
*
|
|
452
|
+
* `/blog/:slug` → `/blog` · `/docs/:path*` → `/docs` · `/:slug` → `/`.
|
|
453
|
+
*
|
|
454
|
+
* ⭐ **The selection is the point, not the slicing.** A page addresses a record only
|
|
455
|
+
* when its route ENDS in a parameter. ⛔ A page nested inside a parametric one —
|
|
456
|
+
* `/members/:slug/cv` — does not, and returns null: composing a record onto it would
|
|
457
|
+
* produce `/members/:slug/alice`, a URL that ranks in an index and 404s on click
|
|
458
|
+
* (the failure hosting measured in their own fixture, 2026-09-12). A static page
|
|
459
|
+
* returns null too, so a page that merely fetches to render itself contributes
|
|
460
|
+
* nothing to a record index.
|
|
461
|
+
*
|
|
462
|
+
* Written for `@uniweb/projections`' `recordRoutes`, which pairs it with
|
|
463
|
+
* `routeQuery` to answer *which of a site's queries become detail pages, and at what
|
|
464
|
+
* base URL* — one mapping a consumer can call instead of re-deriving from page data.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} route - a page's route, parametric or not
|
|
467
|
+
* @returns {string|null} the base route, or null when the page addresses no record
|
|
468
|
+
*/
|
|
469
|
+
export function recordRouteBase(route) {
|
|
470
|
+
if (typeof route !== 'string') return null
|
|
471
|
+
const r = normalizeRoute(route)
|
|
472
|
+
if (!RECORD_ROUTE.test(r)) return null
|
|
473
|
+
const cut = r.lastIndexOf('/')
|
|
474
|
+
return cut <= 0 ? '/' : r.slice(0, cut)
|
|
475
|
+
}
|
|
476
|
+
|
|
394
477
|
/**
|
|
395
478
|
* Fill a route pattern's params from a record — the ONE encoder for a record's href.
|
|
396
479
|
*
|
|
@@ -437,12 +520,15 @@ export function fillRoutePattern(pattern, values) {
|
|
|
437
520
|
head = pattern.slice(0, tail.index)
|
|
438
521
|
}
|
|
439
522
|
const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
|
|
440
|
-
|
|
441
|
-
|
|
523
|
+
// ⭐ THE FIRST MEMBER of a `multi` field — a record has ONE canonical href, and
|
|
524
|
+
// every member routes to it (`routeParamValues`). Baked whole, a `['a','b']`
|
|
525
|
+
// field produced `/tags/a%2Cb`, a URL no lane matches.
|
|
526
|
+
const [value] = routeParamValues(values, name)
|
|
527
|
+
if (value === undefined) {
|
|
442
528
|
missing = true
|
|
443
529
|
return ''
|
|
444
530
|
}
|
|
445
|
-
return encodeURIComponent(
|
|
531
|
+
return encodeURIComponent(value)
|
|
446
532
|
})
|
|
447
533
|
return missing ? null : href + tailHref
|
|
448
534
|
}
|
|
@@ -470,3 +556,49 @@ export function stripLocalePrefix(route, activeLocale, defaultLocale) {
|
|
|
470
556
|
if (route.startsWith(`${prefix}/`)) return route.slice(prefix.length)
|
|
471
557
|
return route
|
|
472
558
|
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* ⭐ **THE PAGE A ROUTE NAMES — one rule, for every lane that asks the question.**
|
|
562
|
+
*
|
|
563
|
+
* Exact match first, then the parametric pages in page order, which is the order
|
|
564
|
+
* `Website#getPage` uses in the SPA. Captured params are decoded by
|
|
565
|
+
* `matchDynamicRoute` (a catch-all per segment), so the values are what a site's
|
|
566
|
+
* query binds against.
|
|
567
|
+
*
|
|
568
|
+
* ⛔ **Why this is a leaf rather than a thing each lane composes.** More than one
|
|
569
|
+
* lane asks *which page does this path name* — the SPA, the runtime's server-side
|
|
570
|
+
* prefetch, and a host's router. Our leaves (`normalizeRoute`, `isDynamicRoute`,
|
|
571
|
+
* `routePatternToRegex`) were importable and the RULE was not, so a consumer
|
|
572
|
+
* imported the parts and wrote the composition itself. **A renamed export breaks
|
|
573
|
+
* loudly; a re-implemented rule drifts silently.** A hand-written matcher spelled
|
|
574
|
+
* `:(\w+)` accepts a different param alphabet than our `[A-Za-z0-9_-]+`, so a
|
|
575
|
+
* hyphenated slug resolves in one copy and 404s in the other, with nothing failing
|
|
576
|
+
* anywhere. Import this; do not rebuild it.
|
|
577
|
+
*
|
|
578
|
+
* ⚠️ **A trailing slash is the same route, on BOTH branches** (`normalizeRoute`),
|
|
579
|
+
* which is what `Website#getPage` has always done. The runtime's own prefetch copy
|
|
580
|
+
* compared raw strings until 2026-09-12 and so disagreed with the SPA about
|
|
581
|
+
* `/about/` — the exact class of split this function exists to end.
|
|
582
|
+
*
|
|
583
|
+
* ⚖️ Matching is not resolving: a hit means the ROUTE exists. Whether the record
|
|
584
|
+
* behind a parametric page exists is a data question answered later.
|
|
585
|
+
*
|
|
586
|
+
* @param {Array<Object>|{pages?: Array<Object>}} source - the pages, or a payload holding them
|
|
587
|
+
* @param {string} route - the concrete path
|
|
588
|
+
* @returns {{ page: Object|null, params: Record<string,string> }} `page` is null when nothing matches
|
|
589
|
+
*/
|
|
590
|
+
export function findPageForRoute(source, route) {
|
|
591
|
+
const pages = Array.isArray(source) ? source : source?.pages
|
|
592
|
+
const list = Array.isArray(pages) ? pages : []
|
|
593
|
+
const wanted = normalizeRoute(route)
|
|
594
|
+
|
|
595
|
+
for (const page of list) {
|
|
596
|
+
if (typeof page?.route === 'string' && normalizeRoute(page.route) === wanted) return { page, params: {} }
|
|
597
|
+
}
|
|
598
|
+
for (const page of list) {
|
|
599
|
+
if (typeof page?.route !== 'string' || !isDynamicRoute(page.route)) continue
|
|
600
|
+
const hit = matchDynamicRoute(page.route, wanted)
|
|
601
|
+
if (hit) return { page, params: hit.params }
|
|
602
|
+
}
|
|
603
|
+
return { page: null, params: {} }
|
|
604
|
+
}
|
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, localeLabel } from './locale-config.js'
|
|
14
|
-
import { matchDynamicRoute, decodeRouteValue,
|
|
14
|
+
import { matchDynamicRoute, decodeRouteValue, matchesRouteParam, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
|
|
15
15
|
import { resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
|
|
16
16
|
import { buildDetailConfig } from './detail-url.js'
|
|
17
17
|
import { resolveService } from './services.js'
|
|
@@ -656,7 +656,7 @@ export default class Website {
|
|
|
656
656
|
}
|
|
657
657
|
|
|
658
658
|
if (!currentItem) {
|
|
659
|
-
currentItem = items.find(item =>
|
|
659
|
+
currentItem = items.find(item => matchesRouteParam(item, paramName, paramValue)) ?? null
|
|
660
660
|
}
|
|
661
661
|
|
|
662
662
|
if (currentItem) {
|