@uniweb/core 0.28.0 → 0.29.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/src/website.js CHANGED
@@ -12,7 +12,8 @@ import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
14
  import { matchDynamicRoute, decodeRouteValue, matchesRouteParam, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
15
- import { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches } from './fetch-config.js'
15
+ import { resolveFetchConfigs, pageRouteQuery, recordPages, routeSelection, siteReaches } from './fetch-config.js'
16
+ import { declaredKeys } from './data-keys.js'
16
17
  import { buildDetailConfig } from './detail-url.js'
17
18
  import { findLayoutEntry } from './layout-name.js'
18
19
  import { resolveService } from './services.js'
@@ -87,6 +88,8 @@ export default class Website {
87
88
  this.notFoundPage = null
88
89
  this._dynamicPageData = new Map()
89
90
  this._dynamicPageCache = new Map()
91
+ // query name → the page its records link to (`recordPageFor`), worked out on first ask
92
+ this._recordPages = null
90
93
  this.pages = []
91
94
  this.activePage = null
92
95
  this.pageRoutes = []
@@ -161,6 +164,7 @@ export default class Website {
161
164
  }
162
165
  }
163
166
  this._dynamicPageCache = new Map()
167
+ this._recordPages = null
164
168
 
165
169
  this.pages = regularPages.map((page, index) => new Page(page, index, this))
166
170
  this.buildPageHierarchy()
@@ -573,7 +577,7 @@ export default class Website {
573
577
  const { paramName, paramValue, variables } = routeBinding(templatePage.route, params, originalData.paramName)
574
578
 
575
579
  // Store dynamic context for components to access. ⛔ No `schema`: the key the
576
- // URL narrows is worked out where it is read (`EntityStore._routeKey`), from
580
+ // URL narrows is worked out where it is read (`EntityStore._route`), from
577
581
  // the page, never stored beside it (deleted 2026-09-11).
578
582
  pageData.dynamicContext = {
579
583
  templateRoute: templatePage.route,
@@ -622,6 +626,10 @@ export default class Website {
622
626
  // visitor's page — the "write key ≠ read key" failure.
623
627
  let items = []
624
628
  let currentItem = null
629
+ // ⭐ On the records service the record's own question checks the route query's
630
+ // set (`buildDetailConfig`), so once it has answered, its answer is definitive —
631
+ // the record, or `[]` for not found — with no list to consult.
632
+ let recordAnswered = false
625
633
 
626
634
  if (this.fetcher) {
627
635
  // The same sources the store walks for a section that declares nothing,
@@ -654,10 +662,13 @@ export default class Website {
654
662
  const raw = detailCached?.data
655
663
  const record = Array.isArray(raw) ? raw[0] : raw
656
664
  if (record && typeof record === 'object') currentItem = record
665
+ if (fetchConfig.ask && detailCached && Array.isArray(raw)) recordAnswered = true
657
666
 
658
- // ⛔ The route query's whole selection (`routeSelection`), never a list its
659
- // `limit` cut: a record past the limit is a record, and until 2026-09-13
660
- // this declared its page "Not found" because the cut list was loaded.
667
+ // ⭐ The route query's set (`routeSelection`) — the query as saved, its `limit`
668
+ // included, never a list a fetch narrowed: a record past a fetch's `limit` is a
669
+ // record, and one past the query's is not (ruled 2026-09-14 [Diego]). ⛔ Until
670
+ // 2026-09-13 this read the cut list and declared a record past a list's
671
+ // `limit` "Not found".
661
672
  const cached = this.fetcher.peek(routeSelection(fetchConfig), ctx)
662
673
  items = Array.isArray(cached?.data) ? cached.data : []
663
674
  }
@@ -672,8 +683,9 @@ export default class Website {
672
683
  if (currentItem.description || currentItem.excerpt) {
673
684
  pageData.description = currentItem.description || currentItem.excerpt
674
685
  }
675
- } else if (items.length > 0) {
676
- // The records are loaded but this ID isn't among them — definitive not found
686
+ } else if (items.length > 0 || recordAnswered) {
687
+ // The set is loaded and this ID isn't in it, or the record question answered
688
+ // `[]` — definitive not found
677
689
  pageData.title = 'Not found'
678
690
  pageData.notFound = true
679
691
  }
@@ -685,7 +697,7 @@ export default class Website {
685
697
  // components via content.data, the others via
686
698
  // `fetch: { query, current: exclude }`). The local
687
699
  // `currentItem`/`items` above drive title/description/notFound.
688
- pageData._recordsLoaded = items.length > 0 || currentItem !== null
700
+ pageData._recordsLoaded = items.length > 0 || currentItem !== null || recordAnswered
689
701
  }
690
702
 
691
703
  // Create the page instance
@@ -875,43 +887,60 @@ export default class Website {
875
887
  }
876
888
 
877
889
  /**
878
- * The parametric page that renders ONE record of a query — `{ route, paramName }`
879
- * for the page whose route query (`routeQuery`) lands under `name`, or null when
880
- * the site routes no such page.
881
- *
882
- * ⭐ This is how a caller outside a parametric page learns which record field
883
- * the site's URL is built on: `kit`'s `useEntityDetail` asks it so a hover
884
- * card and the page it links to address the record by the SAME field. It
885
- * hardcoded `slug` until 2026-09-04, which was quietly wrong on any site
886
- * routing `[id]`.
887
- *
888
- * `name` may be the binding key (`content.data.<key>`) or the query's name:
889
- * `useEntityDetail` holds the query name, and the two differ under an `as:`
890
- * override — which this matched by binding key only until 2026-09-11. The first
891
- * page found wins, and only a page whose own folder is the parameter (a page
892
- * nested inside one is not the record's page).
893
- *
894
- * @param {string} name - a binding key or a query name
890
+ * ⭐ THE `content.data` KEYS A COMPONENT RECEIVES — its `data:` (`meta.data`, as the build
891
+ * leans it), then its foundation's `main.js` `data:`, each with its schema ref
892
+ * (`declaredKeys`, `./data-keys.js`; ruled 2026-09-14 [Diego]). A component with no
893
+ * `data:` receives only its foundation's keys, which reach every section because its
894
+ * handlers run for every section.
895
+ *
896
+ * @param {Object|null} meta - the component's runtime meta (`uniweb.getComponentMeta`)
897
+ * @returns {Array<[string, string|null]>}
898
+ */
899
+ declaredKeys(meta) {
900
+ return declaredKeys(meta?.data, this._foundation?.default?.capabilities?.data)
901
+ }
902
+
903
+ /**
904
+ * ⭐ THE PAGE A QUERY'S RECORDS LINK TO — `{ route, paramName }`: the parametric page
905
+ * whose route query IS the query, by name, the first in page order (`recordPages`,
906
+ * `./fetch-config.js`), with its route translated for the active locale. Null when the
907
+ * site has no such page, and then a record of the query has no link.
908
+ *
909
+ * This is what fills a delivered record's `$route` (`EntityStore`) when its fetch picks
910
+ * no `detailPage`, and how a caller outside a parametric page learns which record field
911
+ * the page's URL is built on: `kit`'s `useEntityDetail` asks it so a hover card and the
912
+ * page it links to address the record by the SAME field.
913
+ *
914
+ * ⛔ Replaces `detailTemplateFor(name)` (2026-09-14), which matched a binding key OR a
915
+ * query name — so a page whose route query merely landed under the same key answered
916
+ * for another query's records.
917
+ *
918
+ * @param {string} query - a query's name
895
919
  * @returns {{ route: string, paramName: string } | null}
896
920
  */
897
- detailTemplateFor(name) {
898
- if (!name) return null
899
- for (const data of this._dynamicPageData.values()) {
900
- if (!data?.route || !/\/:[A-Za-z0-9_-]+\*?$/.test(data.route)) continue
901
- const page = this.pages.find((p) => p.route === data.route)
902
- const route = routeQuery({
903
- page: data.fetch,
904
- parent: page?.parent?.fetch,
905
- // the site's binding is a route query for a top-level page only (`siteReaches`)
906
- site: siteReaches(page?.parent) ? this.config?.fetch : null,
907
- sections: sectionFetches(data.sections),
921
+ recordPageFor(query) {
922
+ if (typeof query !== 'string' || !query) return null
923
+ if (!this._recordPages) {
924
+ // ⭐ Every page, by the route PATTERN it answers to — the entity store's reading
925
+ // (`_route`). A static build replaces a parametric page with one concrete page per
926
+ // record (`expandDynamicPages`), each carrying its template's route as
927
+ // `dynamicContext.templateRoute`; the payload it prerenders and hydrates holds those,
928
+ // and no template. ⛔ Read from the templates alone, this found no page there, and
929
+ // no record of a prerendered site had a link (measured on the `dynamic` template).
930
+ this._recordPages = recordPages(this.pages, {
931
+ routeOf: (page) => page?.dynamicContext?.templateRoute ?? page?.route,
932
+ parentOf: (page) => page?.parent ?? null,
933
+ fetchOf: (page) => page?.fetch,
934
+ sectionsOf: (page) => page?._bodySections,
935
+ site: this.config?.fetch,
908
936
  })
909
- if (!route) continue
910
- if (route.key === name || route.config?.query === name) {
911
- return { route: data.route, paramName: routeParamName(data.route, data.paramName) }
912
- }
913
937
  }
914
- return null
938
+ const found = this._recordPages.get(query)
939
+ if (!found) return null
940
+ return {
941
+ route: this.translateRoute(found.route),
942
+ paramName: routeParamName(found.route, found.page?.dynamicContext?.paramName ?? found.page?.paramName),
943
+ }
915
944
  }
916
945
 
917
946
  /**