@uniweb/build 0.29.1 → 0.30.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.
Files changed (41) hide show
  1. package/package.json +7 -7
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +25 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +13 -12
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +85 -77
  26. package/src/uwx/index.js +33 -13
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +6 -6
  33. package/src/uwx/site-project.js +4 -4
  34. package/src/uwx/site.js +143 -22
  35. package/src/uwx/sync-package.js +32 -18
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
package/src/uwx/site.js CHANGED
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // The document mirrors the @uniweb/site-content Model: `info` (brief) · `pages`
12
12
  // (self-nesting; each page carries its `page_sections` as an inline field) ·
13
- // `layout_sections` · `extensions` · `collections`. `info.foundation`
13
+ // `layout_sections` · `extensions` · `queries`. `info.foundation`
14
14
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
15
  // truth).
16
16
  //
@@ -54,13 +54,13 @@ import {
54
54
  processMarkdownFile,
55
55
  } from '../site/content-collector.js'
56
56
  import { normalizeHideIn } from '../site/nav-visibility.js'
57
- import { resolveDefaultLocale, validateLanguageConfig, collectionDataUrl } from '@uniweb/core'
57
+ import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
58
58
  import { emitEntitySyncPackage } from './entity-document.js'
59
59
  import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
60
60
  import { unwrapLocalized } from './backfill.js'
61
61
  import { loadFreeformTranslation } from '../i18n/freeform.js'
62
62
  import { upsertYamlScalar } from './yaml-upsert.js'
63
- import { resolveCollectionsConfig } from './collections-config.js'
63
+ import { resolveQueriesConfig } from './queries-config.js'
64
64
 
65
65
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
66
66
 
@@ -223,36 +223,43 @@ function buildPageData(config, ctx) {
223
223
  let fetch =
224
224
  config.fetch ??
225
225
  (config.data
226
- ? { collection: Array.isArray(config.data) ? config.data[0] : config.data }
226
+ ? { query: Array.isArray(config.data) ? config.data[0] : config.data }
227
227
  : undefined)
228
- // Resolve the build-time `collection:` shorthand to the runtime-fetchable
228
+ // Resolve the authored `query:` shorthand to the runtime-fetchable
229
229
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
230
230
  // A shell/backend-hosted site renders client-side with NO prerender, so the
231
- // runtime fetches this decl directly — and `collection:` is build-time-only, so
231
+ // runtime fetches this decl directly — and `query:` is build-time-only, so
232
232
  // it would never resolve at render (the static build resolves it the same way
233
233
  // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
234
234
  // at `<base>/data/<name>.json`.
235
- if (fetch && typeof fetch.collection === 'string') {
236
- const { collection, ...rest } = fetch
235
+ if (fetch && typeof fetch.query === 'string') {
236
+ const { query, ...rest } = fetch
237
237
  // ⭐ BOTH, deliberately, and they are not redundant.
238
238
  //
239
- // `collection` — the author's query, unresolved. A consumer that can ask
240
- // a host where collections live (`config.records`) resolves it there,
241
- // which is the only way a live lane is reachable at all: a resolved
242
- // path names one place and closes the question.
239
+ // `query` — the author's named query, unresolved. A consumer that can ask
240
+ // a host where records live (`config.records`) resolves it there, which
241
+ // is the only way a live lane is reachable at all: a resolved path names
242
+ // one place and closes the question.
243
243
  //
244
244
  // `path` — the compiled artifact, the answer when nobody declares a lane.
245
245
  // Also what a consumer still reading `fetch.path` gets, so teaching the
246
246
  // wire a new field does not break one that has not learned it.
247
247
  //
248
- // `@uniweb/core`'s resolveFetchConfigs gives `collection` precedence and
249
- // drops `path` once it has resolved an address — matching parseFetchConfig,
250
- // which has always returned early on `collection`.
248
+ // `@uniweb/core`'s resolveFetchConfigs gives the query precedence and drops
249
+ // `path` once it has resolved an address — matching parseFetchConfig, which
250
+ // has always returned early on the shorthand.
251
251
  //
252
- // `schema` (the collection name) is BOTH the content.data key and part of the
252
+ // `schema` (the query name) is BOTH the content.data key and part of the
253
253
  // dataStore cache key (deriveCacheKey hashes {path,url,endpoint,schema,…};
254
- // `collection` is ignored). Mirrors the static build's parseFetchConfig.
255
- fetch = { collection, path: collectionDataUrl(collection), schema: collection, ...rest }
254
+ // the shorthand is ignored). Mirrors the static build's parseFetchConfig.
255
+ // `query`, END TO END no crossing. An earlier version emitted `collection`
256
+ // here on the belief that this field was the backend's to name. MEASURED
257
+ // otherwise: framework already ships `transform`, `detailPage`, `merge` and
258
+ // `prerender` inside this same `fetch` object, which no backend could be
259
+ // validating — so `fetch` is a blob they carry and framework owns its
260
+ // vocabulary. ⇒ There was nothing to coordinate, and inventing a coordination
261
+ // is how a name stays wrong.
262
+ fetch = { query, path: queryDataUrl(query), schema: query, ...rest }
256
263
  }
257
264
  setIf(data, 'fetch', fetch)
258
265
  if (isDynamic) {
@@ -667,11 +674,91 @@ export function isSiteRelativeExtensionUrl(decl) {
667
674
  * rename every collection at once and the section goes all-blank and is refused.
668
675
  * That is the semantics, not a defect.
669
676
  *
677
+ * ⛔ DO NOT TRIM THE FIELDS BELOW, even the ones the backend never reads.
678
+ *
679
+ * The backend destructures exactly two — `name` and `schema` — and projects none of
680
+ * this Section into a published payload, so the rest read as dead weight. ⛔ THEY ARE
681
+ * OURS, AND THAT IS REASON ENOUGH: `excerpt`, `deferred`, `detailUrl` and `queryable`
682
+ * are read across FRAMEWORK's own runtime, build and kit — `useQueryable`
683
+ * is a public hook a foundation calls to render a filter UI. They drive the file
684
+ * lane, where they work. "The backend does not read it" was never an argument that
685
+ * nothing reads it.
686
+ *
687
+ * ⚠️ There may be a second reason, and it is NOT ours to assert. Backend states that
688
+ * their decl type reaches the app lane verbatim and that their reconcile
689
+ * replaces an item's `data` wholesale with no field-grain merge — from which an
690
+ * omitted field the EDITOR set would be destroyed on the next push. The mechanism is
691
+ * their code and theirs to state. **Whether the editor reads or writes this decl at
692
+ * all is FRONTEND's, and neither framework nor backend has established it.** Treat it
693
+ * as an open hypothesis, not a fact — see the doc below.
694
+ *
695
+ * ⚠️ Separately measured: framework does not emit `label` and has no such collection
696
+ * field — ours belongs to a `folders:` BRANCH. Backend's fixture asserted we mirror a
697
+ * `site.yml collections.<name>.label`; no such field has ever existed, and they have
698
+ * corrected it.
699
+ *
700
+ * ⇒ Full record, including what is established vs merely claimed:
701
+ * `kb/framework/build/collections-decl-open-questions.md`.
702
+ *
670
703
  * @param {object} declarations resolved collection declarations, keyed by name
671
704
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
672
705
  * response or a pull. Absent on a first sync, where minting is correct.
673
706
  */
674
- function collectionsNested(declarations, uuids = null) {
707
+ // KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
708
+ // is emitted, including fields this build does not model — see the note in
709
+ // `queriesNested`. Enumerated here rather than inverted into an allowlist
710
+ // because framework OWNS this vocabulary and can therefore enumerate it
711
+ // truthfully; it does not own the Model's, and cannot.
712
+ //
713
+ // Sources, both framework's own: `site/query-processor.js::parseQueryConfig`
714
+ // (the decl parser) and `site/queries-config.js` (normalization). Pinned by
715
+ // `tests/uwx-decl-unmodelled-fields.test.js`, which fails if either gains a field
716
+ // that is neither emitted nor listed here.
717
+ // Authored keys the explicit block in `queriesNested` already consumes. Kept
718
+ // separate from the framework-local set below because these DO reach the wire —
719
+ // just under a wire spelling. ⚠️ `detailUrl` is the one that matters: it is emitted
720
+ // as `detail_url`, so a pass-through keyed on "is it already in `data`?" does not
721
+ // see it and the field rides TWICE. Measured 2026-08-29, in the first draft of this
722
+ // very change — and the push test missed it because both its controls (`limit`,
723
+ // `schema`) keep their names.
724
+ const DECL_EMITTED_ABOVE = new Set([
725
+ 'source',
726
+ 'schema',
727
+ 'sort',
728
+ 'where',
729
+ 'limit',
730
+ 'excerpt',
731
+ 'deferred',
732
+ 'detailUrl',
733
+ 'queryable'
734
+ ])
735
+
736
+ const DECL_NOT_ON_WIRE = new Set([
737
+ // Identity — rides as the record's own `name`, not inside `data`.
738
+ 'name',
739
+ // Folded into `source` above.
740
+ 'path',
741
+ 'url',
742
+ // Folded into `schema` above (the migration synonym).
743
+ 'model',
744
+ // Build state: whether the AUTHOR asked for the schema or the subfolder-name
745
+ // convention supplied it. Decides hard-error vs soft-skip during sync;
746
+ // `collections-config.js::toConfigQueries` strips it downstream too.
747
+ 'schemaExplicit',
748
+ // ⭐ FRAMEWORK-LOCAL, and the one that proves the rule. `route:` is a real
749
+ // authored field — `parseQueryConfig` reads it, and `collectItems` composes
750
+ // each item's link as `<route>/<slug>` — but the backend's Model has no slot for
751
+ // it, so emitting it would be sending build-time config to a store that validates
752
+ // against a declared schema. Measured 2026-08-29: a first version of this change
753
+ // passed unknown keys through blindly and would have started sending `route` from
754
+ // every site that declares one.
755
+ 'route',
756
+ // Legacy predicate, translated to the canonical `where` upstream. No legacy
757
+ // fields on the wire.
758
+ 'filter'
759
+ ])
760
+
761
+ function queriesNested(declarations, uuids = null) {
675
762
  const out = []
676
763
  for (const [name, d] of Object.entries(declarations)) {
677
764
  const data = {}
@@ -687,6 +774,33 @@ function collectionsNested(declarations, uuids = null) {
687
774
  setIf(data, 'deferred', d.deferred)
688
775
  setIf(data, 'detail_url', d.detailUrl)
689
776
  setIf(data, 'queryable', d.queryable)
777
+ // ⛔ EMIT WHAT WE DO NOT MODEL. The decl's field set is the BACKEND's Model
778
+ // (this document mirrors `@uniweb/site-content` — see the lane header), and
779
+ // their reconcile replaces `data` WHOLESALE with no field-grain merge. So an
780
+ // allowlist here does not merely fail to send an unmodelled field: it DESTROYS
781
+ // whatever was stored under it, silently, on every push.
782
+ //
783
+ // ⚠️ Measured 2026-08-29: the Model declares ELEVEN decl fields and this emitter
784
+ // knew ten. The eleventh is `label`, which framework has no authoring concept
785
+ // for — `label` in framework is a `folders:` BRANCH field (`{segment, label,
786
+ // entries}`), not a property of a collection.
787
+ //
788
+ // ⭐ `label` is the instance, not the defect. Any field the Model gains that we
789
+ // have not taught this function repeats it, and nothing reports the loss. Hence
790
+ // a DENY-list: framework can enumerate its own vocabulary truthfully and cannot
791
+ // enumerate the Model's, so the safe inversion is "withhold what is ours".
792
+ //
793
+ // ⚖️ We do NOT warn on an unrecognized key. Framework cannot tell a valid Model
794
+ // field from a typo — only the server can, and it validates every write against
795
+ // the declared schema. Its rejection is the honest signal; a guess from here
796
+ // would cry wolf on every legitimate new field. Same rule and same reasoning as
797
+ // `site/fetch-shapes.js`: drop only what is DERIVABLE, never what is merely
798
+ // unrecognized.
799
+ for (const [key, value] of Object.entries(d)) {
800
+ if (value === undefined) continue
801
+ if (DECL_EMITTED_ABOVE.has(key) || DECL_NOT_ON_WIRE.has(key)) continue
802
+ data[key] = value
803
+ }
690
804
  const rec = withIdentity(name, { name, ...data })
691
805
  const uuid = uuids?.[name]
692
806
  if (typeof uuid === 'string' && uuid) rec.$uuid = uuid
@@ -709,7 +823,7 @@ function collectionsNested(declarations, uuids = null) {
709
823
  * the site's effective default locale (`defaultLanguage || languages[0] ||
710
824
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
711
825
  * @returns {Promise<object>} the section-keyed `$`-document:
712
- * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, collections }`
826
+ * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
713
827
  */
714
828
  export async function siteProjectToDocument(siteRoot, opts = {}) {
715
829
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
@@ -881,7 +995,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
881
995
 
882
996
  // Collection DECLARATIONS — the merged collections.yml + site.yml::collections
883
997
  // config (the records themselves are separate entities; this is just the config).
884
- const colConfig = await resolveCollectionsConfig(siteRoot, { siteYml })
998
+ const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
885
999
 
886
1000
  // `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
887
1001
  // `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
@@ -895,7 +1009,14 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
895
1009
  doc.pages = pages
896
1010
  doc.layout_sections = layoutSections
897
1011
  doc.extensions = extensionsNested(siteYml)
898
- doc.collections = collectionsNested(colConfig.declarations, opts.collectionUuids)
1012
+ // THE SECTION IS `queries`. Backend renamed it on `@uniweb/site-content`
1013
+ // (2026-08-29) and explains it as named queries — content that is RESOLVED AT
1014
+ // RUNTIME rather than rendered, which is framework's own model of it
1015
+ // (`records-model.md` §1: a query is second-order site content).
1016
+ //
1017
+ // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1018
+ // consumer sees, leave the identifier alone.
1019
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
899
1020
  return doc
900
1021
  }
901
1022
 
@@ -2,7 +2,7 @@
2
2
  // lanes, each its own `.uwx`:
3
3
  //
4
4
  // - site-content lane → one `@uniweb/site-content` entity (the static half).
5
- // - collections lane → one `@uniweb/folder` entity + the collection records it
5
+ // - records lane → one `@uniweb/folder` entity + the record entities it
6
6
  // references (the dynamic half; the `$ref` closure rides
7
7
  // together so brand-new records resolve in one call).
8
8
  //
@@ -14,12 +14,12 @@
14
14
  //
15
15
  // "Send only changed" spans both lanes via one content-hash map (the sync-cache):
16
16
  // - site-content lane fires iff the site entity changed.
17
- // - collections lane fires iff the folder changed OR any record changed — and when
17
+ // - records lane fires iff the folder changed OR any record changed — and when
18
18
  // it fires it carries the FULL folder (for the `$ref` closure + binding) plus the
19
- // changed records. An untouched site with collections pushes nothing on either
19
+ // changed records. An untouched site with records pushes nothing on either
20
20
  // lane (the idempotent no-op).
21
21
 
22
- import { buildCollectionEntities, entityContentHash } from './collections.js'
22
+ import { buildRecordEntities, entityContentHash } from './records.js'
23
23
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
24
24
  import { buildFolderEntity } from './folder.js'
25
25
  import { siteProjectToDocument } from './site.js'
@@ -235,11 +235,11 @@ function rewriteEntityAssets(node, map, ids) {
235
235
  * @param {object} [opts.exporter] @param {string} [opts.exportedAt]
236
236
  * @returns {Promise<{
237
237
  * siteContent: { buffer, entityCount, index, models }|null,
238
- * collections: { buffer, entityCount, index, models }|null,
238
+ * records: { buffer, entityCount, index, models }|null,
239
239
  * hashes: Object<string,string>, warnings: string[], skipped: number,
240
240
  * schemaless: Array<{name: string, model: string}>, localAssets: string[],
241
241
  * applied: object }>}
242
- * `schemaless` lists collections that resolved no data schema (soft-skipped from
242
+ * `schemaless` lists queries that resolved no data schema (soft-skipped from
243
243
  * the sync) — the composite deploy delivers these statically via the data ball.
244
244
  * `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
245
245
  * must upload + rewrite to serve URLs; co-located refs are warned and skipped.
@@ -247,7 +247,7 @@ function rewriteEntityAssets(node, map, ids) {
247
247
  * (`assetRewrite` / `assetIds` / `injectInfo` / `injectExtensions`), ready to be
248
248
  * passed straight back as opts. A caller that banks the `hashes` must bank this
249
249
  * beside them, or an offline re-emit cannot reproduce the document they describe.
250
- * Each lane is null when it has nothing to push. The collections `index` keeps a
250
+ * Each lane is null when it has nothing to push. The records `index` keeps a
251
251
  * leading `{ kind: 'folder' }` placeholder (submission position 0 → the folder
252
252
  * entity) so record back-fill stays positionally aligned; the folder itself has no
253
253
  * uuid to back-fill.
@@ -261,22 +261,31 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
261
261
  const exporter = opts.exporter
262
262
  const exportedAt = opts.exportedAt
263
263
 
264
- const col = await buildCollectionEntities(siteRoot, {
264
+ const col = await buildRecordEntities(siteRoot, {
265
265
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
266
266
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
267
267
  ...(sourceLocale ? { sourceLocale } : {}),
268
268
  // The publish org — resolves a foundation-relative `@/x` model ref into
269
269
  // `@org/x` before it ships. Absent on an offline probe, which is why
270
- // buildCollectionEntities warns rather than throws.
270
+ // buildRecordEntities warns rather than throws.
271
271
  ...(opts.org ? { org: opts.org } : {}),
272
272
  })
273
- const warnings = [...col.warnings]
273
+ const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
274
274
 
275
275
  // The folder rides over the FULL record set (before filtering) so its references
276
276
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
277
277
  const folder = buildFolderEntity({
278
278
  recordEntities: col.entities,
279
- folders: col.colConfig?.folders ?? null,
279
+ // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
280
+ // collection — which made the folder a shadow of a directory layout rather
281
+ // than something the author states.
282
+ folderNodes: col.folder?.nodes ?? [],
283
+ // ⛔ Whether `records.yml` EXISTS — not whether it holds anything. Missing is
284
+ // inert; empty is a folder that REMOVES. Compared against the affirmative
285
+ // value, never `!== 'missing'`: an absent state would read as declared, and
286
+ // that is precisely how a site with no records.yml once emitted a folder that
287
+ // would have emptied the live one.
288
+ declared: col.recordsState === 'empty' || col.recordsState === 'declared',
280
289
  // Placement identity from the folder document a previous push returned.
281
290
  // Absent on a first push — every item is genuinely new then. Its ABSENCE on a
282
291
  // later push is what made `publish` after `push` fail: send-only-changed skips
@@ -284,13 +293,18 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
284
293
  // the payload whose item identity has to survive.
285
294
  ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
286
295
  })
296
+ // ⚠️ A placement that produced no entity is dropped by the builder rather than
297
+ // sent pointing at nothing — but it must still be SAID. It means an entity was
298
+ // placed in records.yml and then skipped upstream (a schema that did not
299
+ // resolve, most often), and the record is simply absent from the site.
300
+ if (folder?.warnings?.length) warnings.push(...folder.warnings)
287
301
 
288
- // `collectionUuids` — identity for the `collections` section, keyed by collection
289
- // NAME because a declaration has no file of its own (see collectionsNested).
302
+ // `queryUuids` — identity for the `queries` section, keyed by query
303
+ // NAME because a declaration has no file of its own (see queriesNested).
290
304
  const siteDoc = includeSite
291
305
  ? await siteProjectToDocument(siteRoot, {
292
306
  sourceLocale,
293
- ...(opts.collectionUuids ? { collectionUuids: opts.collectionUuids } : {})
307
+ ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {})
294
308
  })
295
309
  : null
296
310
  // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
@@ -398,14 +412,14 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
398
412
  return isChanged
399
413
  }
400
414
 
401
- // --- collections lane --------------------------------------------------------
415
+ // --- records lane ------------------------------------------------------------
402
416
  // changed() has side effects (hashes/skipped), so evaluate every entity exactly
403
417
  // once, in a stable order: folder, then each record.
404
418
  const folderChanged = folder ? changed(folder) : false
405
419
  const recordChanged = col.entities.map((e, i) => ({ entity: e, index: col.index[i], changed: changed(e) }))
406
420
  const changedRecords = recordChanged.filter((r) => r.changed)
407
421
 
408
- let collections = null
422
+ let records = null
409
423
  if (folder && (folderChanged || changedRecords.length > 0)) {
410
424
  // Folder first (always, for the `$ref` closure), then changed records. The
411
425
  // leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
@@ -416,7 +430,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
416
430
  // filtered out here by send-only-changed. Declare them all (the backend rejects a
417
431
  // folder that references an undeclared Model).
418
432
  const referencedModels = [...collectReferencedModels(folder.document, new Set())]
419
- collections = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
433
+ records = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
420
434
  }
421
435
 
422
436
  // --- site-content lane -------------------------------------------------------
@@ -455,7 +469,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
455
469
  }
456
470
 
457
471
  return {
458
- siteContent, collections, siteContentUuid, hashes, warnings, skipped,
472
+ siteContent, records, siteContentUuid, hashes, warnings, skipped,
459
473
  schemaless: col.schemaless, localAssets, applied,
460
474
  // { stamped, unknown } when identity was applied; null when the caller passed
461
475
  // no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
27
27
  import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
- import { collectionNameFromUrl } from '@uniweb/core'
30
+ import { queryNameFromUrl } from '@uniweb/core'
31
31
 
32
32
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
33
33
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -39,7 +39,7 @@ export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
39
39
  import { buildSchema } from './schema.js'
40
40
  import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
41
41
  import { collectSiteContent } from './site/content-collector.js'
42
- import { processCollections } from './site/collection-processor.js'
42
+ import { processQueries } from './site/query-processor.js'
43
43
 
44
44
  // --- the join: sections ↔ schemas -------------------------------------------
45
45
 
@@ -58,7 +58,7 @@ import { processCollections } from './site/collection-processor.js'
58
58
  * this command and the build disagree about what feeds what.
59
59
  *
60
60
  * Data is acquired without a full build: the foundation schema via schema
61
- * discovery, the site sections via the content collector, the collections via
61
+ * discovery, the site sections via the content collector, the byQuery via
62
62
  * the collection processor (in-memory, full records — so `deferred:`
63
63
  * field-stripping never causes a false "missing required").
64
64
  *
@@ -85,15 +85,13 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
85
85
  const config = site.config || {}
86
86
  const basePath = typeof config.base === 'string' ? config.base : '/'
87
87
 
88
- // Compile file-based collections in-memory (the same step the data-only
89
- // pipeline runs). Full records — `writeCollectionFiles` is the stage that
88
+ // Compile file-based byQuery in-memory (the same step the data-only
89
+ // pipeline runs). Full records — `writeQueryFiles` is the stage that
90
90
  // strips `deferred:` fields, and we skip it.
91
- let collections = {}
92
- if (config.collections && typeof config.collections === 'object') {
93
- const collectionsBase = config.paths?.collections
94
- ? resolve(siteRoot, config.paths.collections)
95
- : null
96
- collections = await processCollections(siteRoot, config.collections, collectionsBase, basePath)
91
+ let byQuery = {}
92
+ if (config.queries && typeof config.queries === 'object') {
93
+
94
+ byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
97
95
  }
98
96
 
99
97
  // Pass 1 — discover unique (file, schema-ref) pairs and who uses each.
@@ -144,7 +142,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
144
142
  let recordCount = 0
145
143
 
146
144
  for (const entry of work.values()) {
147
- const { records, error } = await resolveRecords(entry.path, { collections, siteRoot })
145
+ const { records, error } = await resolveRecords(entry.path, { byQuery, siteRoot })
148
146
  if (error) {
149
147
  setupErrors.push({ file: entry.path, message: error, users: entry.users })
150
148
  continue
@@ -252,7 +250,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
252
250
  * works on a link-mode site whose foundation is a registry ref with nothing
253
251
  * local), and an unresolved tag must be silent rather than an error. So the
254
252
  * package is resolved from this build's own graph, where it is an
255
- * optionalDependency, exactly as `i18n/collections.js` resolves it.
253
+ * optionalDependency, exactly as `i18n/records.js` resolves it.
256
254
  *
257
255
  * @param {Object} site - collected site content (`{ pages }`)
258
256
  * @returns {Promise<{ violations: Array, schemas: Set<string>, checked: number }>}
@@ -337,7 +335,7 @@ function nodesOfType(doc, type) {
337
335
  * This is the pass that closes an odd hole: a component declares
338
336
  * `data: { form: '@std/form' }`, an author writes a ```` ```yaml:form ```` block,
339
337
  * and until now **nothing checked one against the other**. The join walked
340
- * `section.fetch` — collections and fetches — so a schema bound to a key that a
338
+ * `section.fetch` — byQuery and fetches — so a schema bound to a key that a
341
339
  * tagged block fills was never applied to anything. `@std/form` existed for
342
340
  * exactly this and had never run outside its own contract test.
343
341
  *
@@ -462,17 +460,17 @@ function walkSections(sections, visit) {
462
460
  }
463
461
 
464
462
  /**
465
- * Resolve a fetch `path` to its records. Declared collections come from the
463
+ * Resolve a fetch `path` to its records. Declared byQuery come from the
466
464
  * in-memory compile (full records, current); a bare file under `public/`
467
465
  * (hand-authored data) is read from disk. Either way no prior build is needed.
468
466
  */
469
- async function resolveRecords(path, { collections, siteRoot }) {
467
+ async function resolveRecords(path, { byQuery, siteRoot }) {
470
468
  // A compiled-collection URL → a declared collection? Use the compiled
471
469
  // records. Anything else falls through to the file read below.
472
- const name = collectionNameFromUrl(path)
470
+ const name = queryNameFromUrl(path)
473
471
  let records
474
- if (Object.prototype.hasOwnProperty.call(collections, name)) {
475
- records = collections[name]
472
+ if (Object.prototype.hasOwnProperty.call(byQuery, name)) {
473
+ records = byQuery[name]
476
474
  } else {
477
475
  // Otherwise read the file from public/ (the data-fetcher's resolution root).
478
476
  const filePath = join(siteRoot, 'public', path)