@uniweb/core 0.25.0 → 0.26.0
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 +3 -0
- package/src/page.js +1 -1
- package/src/route-match.js +89 -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,13 @@ 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,
|
|
71
74
|
} from './route-match.js'
|
|
72
75
|
export { isRichSchema } from './schemas.js'
|
|
73
76
|
// ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
|
package/src/page.js
CHANGED
|
@@ -104,7 +104,7 @@ export default class Page {
|
|
|
104
104
|
// Scroll position memory (for navigation restoration)
|
|
105
105
|
this.scrollY = 0
|
|
106
106
|
|
|
107
|
-
// Fetch configuration (
|
|
107
|
+
// Fetch configuration (page.yml's `fetch:`, or its `query:` shorthand, desugared at build)
|
|
108
108
|
// Preserved at runtime so EntityStore can walk the page hierarchy
|
|
109
109
|
this.fetch = pageData.fetch || null
|
|
110
110
|
|
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
|
}
|
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) {
|