@uniweb/core 0.25.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,8 +44,8 @@
44
44
  "vitest": "^4.1.7"
45
45
  },
46
46
  "dependencies": {
47
- "@uniweb/semantic-parser": "^1.4.0",
48
- "@uniweb/theming": "^0.1.15"
47
+ "@uniweb/theming": "^0.1.15",
48
+ "@uniweb/semantic-parser": "^1.4.0"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "vitest run"
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs, routeQuery, sectionFetches } from './fetch-config.js'
18
- import { fillRoutePattern, routeParamValue } from './route-match.js'
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) => String(routeParamValue(item, paramName)) !== String(paramValue))
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) => String(routeParamValue(item, paramName)) === String(paramValue))
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) => String(routeParamValue(item, paramName)) !== String(paramValue))
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) => String(routeParamValue(item, paramName)) === String(paramValue)
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
@@ -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
- const value = routeParamValue(values, name)
441
- if (value === undefined || value === null || value === '') {
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(String(value))
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, routeParamValue, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
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 => String(routeParamValue(item, paramName)) === String(paramValue)) ?? null
659
+ currentItem = items.find(item => matchesRouteParam(item, paramName, paramValue)) ?? null
660
660
  }
661
661
 
662
662
  if (currentItem) {