@uniweb/build 0.29.1 → 0.30.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.
Files changed (41) hide show
  1. package/package.json +5 -5
  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 +35 -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 +92 -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 +30 -5
  34. package/src/uwx/site.js +295 -27
  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
@@ -16,12 +16,14 @@ import { join } from 'node:path'
16
16
  import { pathToFileURL } from 'node:url'
17
17
  import { DATA_DIR } from '@uniweb/core'
18
18
  import { computeHash } from './hash.js'
19
- import { loadFreeformCollectionItem } from './freeform.js'
19
+ import { loadFreeformRecord } from './freeform.js'
20
20
  // The heuristic judgement about which strings inside structured data are prose.
21
21
  // It lives in its own module because the page lane needs exactly the same
22
22
  // answer for a tagged data block's payload — a `label` is prose and an `href`
23
23
  // is not, wherever the value came from. Moved rather than copied: two tuned
24
24
  // denylists would drift, and drift here is silent.
25
+ import { resolveQueriesConfig } from '../site/queries-config.js'
26
+ import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
25
27
  import {
26
28
  NON_TRANSLATABLE_TYPES,
27
29
  HEURISTIC_SKIP_FIELDS,
@@ -29,13 +31,19 @@ import {
29
31
  isStructuralString,
30
32
  } from './data-strings.js'
31
33
 
32
- export const COLLECTIONS_DIR = 'collections'
34
+ // `records`, NOT `collections`. The manifest holds translations of RECORDS —
35
+ // what a site stores — and a record has nothing to do with any query that
36
+ // selects it. The old name kept the two conflated, and the conflation was live:
37
+ // contexts were keyed by the QUERY, so two queries over one schema produced two
38
+ // entries for one record, each invisible from the other, and a renamed query
39
+ // orphaned every translation under it.
40
+ export const RECORDS_DIR = 'records'
33
41
 
34
42
  // ---------------------------------------------------------------------------
35
43
  // Schema resolution
36
44
  // ---------------------------------------------------------------------------
37
45
 
38
- /** Cache for resolved schemas (collection name → schema or null) */
46
+ /** Cache for resolved schemas (query name → schema or null) */
39
47
  const schemaCache = new Map()
40
48
 
41
49
  /**
@@ -46,13 +54,13 @@ const schemaCache = new Map()
46
54
  * 2. Standard schema: @uniweb/schemas by collection name (with naive singularization)
47
55
  * 3. null (no schema found → heuristic fallback)
48
56
  *
49
- * @param {string} collectionName
57
+ * @param {string} queryName
50
58
  * @param {string} siteRoot
51
59
  * @returns {Promise<Object|null>}
52
60
  */
53
- async function resolveSchema(collectionName, siteRoot) {
54
- if (schemaCache.has(collectionName)) {
55
- return schemaCache.get(collectionName)
61
+ async function resolveSchema(queryName, siteRoot) {
62
+ if (schemaCache.has(queryName)) {
63
+ return schemaCache.get(queryName)
56
64
  }
57
65
 
58
66
  let schema = null
@@ -64,12 +72,12 @@ async function resolveSchema(collectionName, siteRoot) {
64
72
  // an author to write into it was the one remaining place the framework
65
73
  // contradicted its own rule that `collections/` is the only way to provide
66
74
  // structured data. The schema describes the source, so it lives with it.
67
- const companionPath = join(siteRoot, 'collections', `${collectionName}.schema.js`)
75
+ const companionPath = join(siteRoot, ENTITIES_DIR, `${queryName}.schema.js`)
68
76
  if (existsSync(companionPath)) {
69
77
  try {
70
78
  const mod = await import(pathToFileURL(companionPath).href)
71
79
  schema = mod.default || mod
72
- schemaCache.set(collectionName, schema)
80
+ schemaCache.set(queryName, schema)
73
81
  return schema
74
82
  } catch (err) {
75
83
  console.warn(`[i18n] Failed to load companion schema ${companionPath}: ${err.message}`)
@@ -79,7 +87,7 @@ async function resolveSchema(collectionName, siteRoot) {
79
87
  // 2. Standard schema from @uniweb/schemas (try exact name + singularized)
80
88
  try {
81
89
  const schemasModule = await import('@uniweb/schemas')
82
- const names = [collectionName, singularize(collectionName)]
90
+ const names = [queryName, singularize(queryName)]
83
91
 
84
92
  for (const name of names) {
85
93
  if (schemasModule.schemas?.[name]) {
@@ -91,7 +99,7 @@ async function resolveSchema(collectionName, siteRoot) {
91
99
  // @uniweb/schemas not installed — that's fine
92
100
  }
93
101
 
94
- schemaCache.set(collectionName, schema)
102
+ schemaCache.set(queryName, schema)
95
103
  return schema
96
104
  }
97
105
 
@@ -151,12 +159,12 @@ function isFieldTranslatable(fieldDef) {
151
159
  *
152
160
  * @param {Object} item - Data item
153
161
  * @param {Object} schema - Schema with `fields`
154
- * @param {string} collectionName
162
+ * @param {string} queryName
155
163
  * @param {Object} units - Accumulator
156
164
  */
157
- function extractWithSchema(item, schema, collectionName, units) {
165
+ function extractWithSchema(item, schema, recordDir, units) {
158
166
  const slug = item.slug || item.id || item.name || 'unknown'
159
- const context = { collection: collectionName, item: slug }
167
+ const context = { record: `${recordDir}/${slug}` }
160
168
 
161
169
  extractFromItemWithSchema(item, schema.fields, '', context, units)
162
170
 
@@ -216,9 +224,9 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
216
224
  * Extract translatable fields from an item using heuristics.
217
225
  * Recursively walks the data, extracting strings that look like human-readable text.
218
226
  */
219
- function extractHeuristic(item, collectionName, units) {
227
+ function extractHeuristic(item, recordDir, units) {
220
228
  const slug = item.slug || item.id || item.name || 'unknown'
221
- const context = { collection: collectionName, item: slug }
229
+ const context = { record: `${recordDir}/${slug}` }
222
230
 
223
231
  extractFromItemHeuristic(item, '', context, units, 0)
224
232
 
@@ -376,13 +384,42 @@ function translateItemHeuristic(data, context, translations, depth) {
376
384
  // Main extraction entry point
377
385
  // ---------------------------------------------------------------------------
378
386
 
387
+ /**
388
+ * Query name → the pool directory its records live in (`article`, `std/person`).
389
+ *
390
+ * ⛔ A TRANSLATION BELONGS TO A RECORD, NOT TO A QUERY. The manifest is keyed by
391
+ * the record's pool identity for exactly the reason the freeform tree is: two
392
+ * queries can cover one schema, so keying by the query would ask an author to
393
+ * translate the same record once per query, find neither from the other, and
394
+ * lose both when a query is renamed. A record's identity is a fact about the
395
+ * site; a query's name is a choice.
396
+ *
397
+ * Falls back to the query name for a source with no local pool (a remote `url:`),
398
+ * where there is no record on disk to be identified.
399
+ */
400
+ async function poolDirsByQuery(siteRoot) {
401
+ const out = new Map()
402
+ try {
403
+ const { declarations } = await resolveQueriesConfig(siteRoot)
404
+ for (const [name, decl] of Object.entries(declarations || {})) {
405
+ const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
406
+ out.set(name, dirs ? dirs.join('/') : name)
407
+ }
408
+ } catch {
409
+ // No resolvable config — every record keys by its query name, which is what
410
+ // the extractor did before records existed.
411
+ }
412
+ return out
413
+ }
414
+
415
+
379
416
  /**
380
417
  * Extract translatable content from all collections
381
418
  * @param {string} siteRoot - Site root directory
382
419
  * @param {Object} options - Options
383
420
  * @returns {Promise<Object>} Manifest with translation units
384
421
  */
385
- export async function extractCollectionContent(siteRoot, options = {}) {
422
+ export async function extractRecordContent(siteRoot, options = {}) {
386
423
  const dataDir = join(siteRoot, 'public', DATA_DIR)
387
424
 
388
425
  if (!existsSync(dataDir)) {
@@ -390,6 +427,7 @@ export async function extractCollectionContent(siteRoot, options = {}) {
390
427
  }
391
428
 
392
429
  const units = {}
430
+ const poolDirs = await poolDirsByQuery(siteRoot)
393
431
 
394
432
  let files
395
433
  try {
@@ -401,7 +439,7 @@ export async function extractCollectionContent(siteRoot, options = {}) {
401
439
  const jsonFiles = files.filter(f => f.endsWith('.json'))
402
440
 
403
441
  for (const file of jsonFiles) {
404
- const collectionName = file.replace('.json', '')
442
+ const queryName = file.replace('.json', '')
405
443
  const filePath = join(dataDir, file)
406
444
 
407
445
  try {
@@ -411,18 +449,19 @@ export async function extractCollectionContent(siteRoot, options = {}) {
411
449
  if (!Array.isArray(items)) continue
412
450
 
413
451
  // Resolve schema once per collection
414
- const schema = await resolveSchema(collectionName, siteRoot)
452
+ const schema = await resolveSchema(queryName, siteRoot)
453
+ const recordDir = poolDirs.get(queryName) ?? queryName
415
454
 
416
455
  for (const item of items) {
417
456
  if (schema?.fields) {
418
- extractWithSchema(item, schema, collectionName, units)
457
+ extractWithSchema(item, schema, recordDir, units)
419
458
  } else {
420
- extractHeuristic(item, collectionName, units)
459
+ extractHeuristic(item, recordDir, units)
421
460
  }
422
461
  }
423
462
  } catch (err) {
424
463
  // Skip files that can't be parsed
425
- console.warn(`[i18n] Skipping collection ${file}: ${err.message}`)
464
+ console.warn(`[i18n] Skipping ${file}: ${err.message}`)
426
465
  }
427
466
  }
428
467
 
@@ -512,10 +551,8 @@ function addUnit(units, source, field, context) {
512
551
  if (units[hash]) {
513
552
  const existingContexts = units[hash].contexts || []
514
553
  units[hash].contexts = existingContexts
515
- const contextKey = `${context.collection}:${context.item}`
516
- const exists = existingContexts.some(
517
- c => `${c.collection}:${c.item}` === contextKey
518
- )
554
+ const contextKey = context.record
555
+ const exists = existingContexts.some((c) => c.record === contextKey)
519
556
  if (!exists) {
520
557
  existingContexts.push({ ...context })
521
558
  }
@@ -539,11 +576,11 @@ function addUnit(units, source, field, context) {
539
576
  * @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
540
577
  * @returns {Promise<Object>} Map of locale to output paths
541
578
  */
542
- export async function buildLocalizedCollections(siteRoot, options = {}) {
579
+ export async function buildLocalizedRecords(siteRoot, options = {}) {
543
580
  const {
544
581
  locales = [],
545
582
  outputDir = join(siteRoot, 'dist'),
546
- collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR),
583
+ recordLocalesDir = join(siteRoot, 'locales', RECORDS_DIR),
547
584
  localesDir = join(siteRoot, 'locales'),
548
585
  freeformEnabled = true
549
586
  } = options
@@ -567,11 +604,20 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
567
604
  return {}
568
605
  }
569
606
 
607
+ // ⚠️ The translate side derives the record key the SAME way extraction does.
608
+ // These two agreeing is the whole contract, and a mismatch is SILENT: lookups
609
+ // simply miss and every string falls back to its source. (This was missing for
610
+ // a while and the failure was swallowed by the per-file catch below — the build
611
+ // stayed green while nothing was translated.)
612
+ const poolDirs = await poolDirsByQuery(siteRoot)
613
+
570
614
  const outputs = {}
615
+ // Reported rather than only logged — see the catch below.
616
+ const failures = []
571
617
 
572
618
  for (const locale of locales) {
573
619
  // Load translations for this locale
574
- const localePath = join(collectionsLocalesDir, `${locale}.json`)
620
+ const localePath = join(recordLocalesDir, `${locale}.json`)
575
621
  let translations = {}
576
622
  if (existsSync(localePath)) {
577
623
  try {
@@ -592,7 +638,7 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
592
638
  outputs[locale] = {}
593
639
 
594
640
  for (const file of jsonFiles) {
595
- const collectionName = file.replace('.json', '')
641
+ const queryName = file.replace('.json', '')
596
642
  const sourcePath = join(dataDir, file)
597
643
 
598
644
  try {
@@ -603,17 +649,18 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
603
649
  // Copy as-is if not an array
604
650
  const destPath = join(localeDataDir, file)
605
651
  await writeFile(destPath, raw)
606
- outputs[locale][collectionName] = destPath
652
+ outputs[locale][queryName] = destPath
607
653
  continue
608
654
  }
609
655
 
610
656
  // Resolve schema once per collection
611
- const schema = await resolveSchema(collectionName, siteRoot)
657
+ const schema = await resolveSchema(queryName, siteRoot)
658
+ const recordDir = poolDirs.get(queryName) ?? queryName
612
659
 
613
660
  // Translate each item (with free-form support)
614
661
  const translatedItems = await Promise.all(
615
662
  items.map(item =>
616
- translateItemAsync(item, collectionName, translations, schema, {
663
+ translateItemAsync(item, recordDir, translations, schema, {
617
664
  locale,
618
665
  localesDir,
619
666
  freeformEnabled: hasFreeform
@@ -623,13 +670,28 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
623
670
 
624
671
  const destPath = join(localeDataDir, file)
625
672
  await writeFile(destPath, JSON.stringify(translatedItems, null, 2))
626
- outputs[locale][collectionName] = destPath
673
+ outputs[locale][queryName] = destPath
627
674
  } catch (err) {
628
- console.warn(`[i18n] Failed to translate collection ${file}: ${err.message}`)
675
+ // ⛔ A FAILURE HERE USED TO BE A `console.warn` AND NOTHING ELSE, and it
676
+ // hid a real bug for the length of a session: a `ReferenceError` in this
677
+ // lane — a programming error, not bad data — was caught by a handler
678
+ // meant for an unparseable file, downgraded to a warning, and the
679
+ // locale's output silently omitted. The build stayed green while NOTHING
680
+ // was translated.
681
+ //
682
+ // ⇒ It is an ERROR, and it is reported in the RESULT. A caller cannot act
683
+ // on a line of stderr it did not read; `failures` is the thing a build can
684
+ // count and refuse on. Still not thrown, because one unparseable data file
685
+ // must not take down a whole multi-locale build.
686
+ console.error(`[i18n] Failed to translate ${file} for ${locale}: ${err.message}`)
687
+ failures.push({ locale, file, message: err.message })
629
688
  }
630
689
  }
631
690
  }
632
691
 
692
+ // ⚠️ Attached rather than merged into the locale map, so an existing reader
693
+ // that indexes `outputs[locale][name]` is unaffected while a new one can ask.
694
+ if (failures.length) Object.defineProperty(outputs, 'failures', { value: failures, enumerable: false })
633
695
  return outputs
634
696
  }
635
697
 
@@ -640,15 +702,15 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
640
702
  * 1. Check for free-form translation (complete or partial replacement)
641
703
  * 2. Fall back to hash-based translation (schema-guided or heuristic)
642
704
  */
643
- async function translateItemAsync(item, collectionName, translations, schema, options = {}) {
705
+ async function translateItemAsync(item, recordDir, translations, schema, options = {}) {
644
706
  const { locale, localesDir, freeformEnabled } = options
645
707
  const translated = { ...item }
646
708
  const slug = item.slug || item.id || item.name || 'unknown'
647
- const context = { collection: collectionName, item: slug }
709
+ const context = { record: `${recordDir}/${slug}` }
648
710
 
649
711
  // Check for free-form translation first
650
712
  if (freeformEnabled && locale && localesDir) {
651
- const freeform = await loadFreeformCollectionItem(item, collectionName, locale, localesDir)
713
+ const freeform = await loadFreeformRecord(item, recordDir, locale, localesDir)
652
714
 
653
715
  if (freeform) {
654
716
  // Merge free-form data (supports partial: frontmatter only, body only, or both)
@@ -668,16 +730,16 @@ async function translateItemAsync(item, collectionName, translations, schema, op
668
730
  }
669
731
 
670
732
  // Fall back to hash-based translation
671
- return translateItemSync(translated, collectionName, translations, schema)
733
+ return translateItemSync(translated, recordDir, translations, schema)
672
734
  }
673
735
 
674
736
  /**
675
737
  * Apply translations to a collection item (sync, hash-based only)
676
738
  */
677
- function translateItemSync(item, collectionName, translations, schema) {
739
+ function translateItemSync(item, recordDir, translations, schema) {
678
740
  const translated = { ...item }
679
741
  const slug = item.slug || item.id || item.name || 'unknown'
680
- const context = { collection: collectionName, item: slug }
742
+ const context = { record: `${recordDir}/${slug}` }
681
743
 
682
744
  if (schema?.fields) {
683
745
  return translateWithSchema(translated, schema, context, translations)
@@ -744,7 +806,8 @@ function lookupTranslation(source, context, translations) {
744
806
  }
745
807
 
746
808
  if (typeof translation === 'object' && translation !== null) {
747
- const contextKey = `${context.collection}:${context.item}`
809
+ // Same key shape the manifest writes — a record's identity.
810
+ const contextKey = context.record
748
811
  if (translation.overrides?.[contextKey]) {
749
812
  return translation.overrides[contextKey]
750
813
  }
@@ -765,7 +828,7 @@ function lookupTranslation(source, context, translations) {
765
828
  * Used by dev server middleware for on-the-fly translation.
766
829
  *
767
830
  * @param {Array} items - Collection items array
768
- * @param {string} collectionName - Collection name (e.g., 'articles')
831
+ * @param {string} recordDir - The record's pool directory (e.g. 'article')
769
832
  * @param {string} siteRoot - Site root directory
770
833
  * @param {Object} options - Translation options
771
834
  * @param {string} options.locale - Target locale code
@@ -774,17 +837,17 @@ function lookupTranslation(source, context, translations) {
774
837
  * @param {boolean} [options.freeformEnabled=false] - Enable free-form translations
775
838
  * @returns {Promise<Array>} Translated items
776
839
  */
777
- export async function translateCollectionData(items, collectionName, siteRoot, options = {}) {
840
+ export async function translateRecordData(items, queryName, siteRoot, options = {}) {
778
841
  const { locale, localesDir, translations = {}, freeformEnabled = false } = options
779
842
 
780
843
  if (!Array.isArray(items)) return items
781
844
 
782
- const schema = await resolveSchema(collectionName, siteRoot)
845
+ const schema = await resolveSchema(queryName, siteRoot)
783
846
 
784
847
  if (freeformEnabled) {
785
848
  return Promise.all(
786
849
  items.map(item =>
787
- translateItemAsync(item, collectionName, translations, schema, {
850
+ translateItemAsync(item, recordDir, translations, schema, {
788
851
  locale,
789
852
  localesDir,
790
853
  freeformEnabled
@@ -794,7 +857,7 @@ export async function translateCollectionData(items, collectionName, siteRoot, o
794
857
  }
795
858
 
796
859
  return items.map(item =>
797
- translateItemSync(item, collectionName, translations, schema)
860
+ translateItemSync(item, queryName, translations, schema)
798
861
  )
799
862
  }
800
863
 
@@ -807,12 +870,12 @@ export async function translateCollectionData(items, collectionName, siteRoot, o
807
870
  * @param {string} localesPath - Path to locales directory
808
871
  * @returns {Promise<string[]>} Array of locale codes
809
872
  */
810
- export async function getCollectionLocales(localesPath) {
811
- const collectionsDir = join(localesPath, COLLECTIONS_DIR)
812
- if (!existsSync(collectionsDir)) return []
873
+ export async function getRecordLocales(localesPath) {
874
+ const recordLocalesDir = join(localesPath, RECORDS_DIR)
875
+ if (!existsSync(recordLocalesDir)) return []
813
876
 
814
877
  try {
815
- const files = await readdir(collectionsDir)
878
+ const files = await readdir(recordLocalesDir)
816
879
  return files
817
880
  .filter(f => f.endsWith('.json') && f !== 'manifest.json')
818
881
  .map(f => f.replace('.json', ''))
package/src/i18n/sync.js CHANGED
@@ -98,8 +98,9 @@ function contextsEqual(contexts1, contexts2) {
98
98
  const c2 = contexts2 || []
99
99
  if (c1.length !== c2.length) return false
100
100
 
101
- const set1 = new Set(c1.map(c => `${c.page || c.collection}:${c.section || c.item}`))
102
- const set2 = new Set(c2.map(c => `${c.page || c.collection}:${c.section || c.item}`))
101
+ const key = (c) => (c.record ? `record:${c.record}` : `${c.page}:${c.section}`)
102
+ const set1 = new Set(c1.map(key))
103
+ const set2 = new Set(c2.map(key))
103
104
 
104
105
  if (set1.size !== set2.size) return false
105
106
  for (const key of set1) {
@@ -113,14 +114,14 @@ function contextsEqual(contexts1, contexts2) {
113
114
  * Returns the previous unit info if found
114
115
  */
115
116
  function findMatchingContext(currentContexts, previousUnits) {
117
+ // One key shape for both lanes: a page/section pair, or a record's identity.
118
+ const key = (c) => (c.record ? `record:${c.record}` : `${c.page}:${c.section}`)
116
119
  for (const context of currentContexts) {
117
- const contextKey = `${context.page}:${context.section}`
120
+ const contextKey = key(context)
118
121
 
119
122
  for (const [hash, unit] of Object.entries(previousUnits)) {
120
123
  const unitContexts = unit.contexts || []
121
- const hasContext = unitContexts.some(
122
- c => `${c.page || c.collection}:${c.section || c.item}` === contextKey
123
- )
124
+ const hasContext = unitContexts.some((c) => key(c) === contextKey)
124
125
  if (hasContext) {
125
126
  return { hash, source: unit.source, contexts: unit.contexts }
126
127
  }
@@ -198,8 +199,8 @@ export function formatSyncReport(report) {
198
199
  */
199
200
  function formatContext(context) {
200
201
  if (!context) return ''
201
- const location = context.page || context.collection || ''
202
- const section = context.section || context.item || ''
202
+ const location = context.page || context.record || ''
203
+ const section = context.section || ''
203
204
  if (!location && !section) return ''
204
205
  return `(${location}:${section})`
205
206
  }
@@ -13,7 +13,7 @@
13
13
  * path (where the static-host extras — sitemap, robots, search-index,
14
14
  * `_pages/*` for split content — are actually consumed). Both paths use
15
15
  * the same underlying building blocks (`collectSiteContent`,
16
- * `processCollections`, `processAssets`, etc.), so behavior stays
16
+ * `processQueries`, `processAssets`, etc.), so behavior stays
17
17
  * consistent without forcing one path through the other's lifecycle.
18
18
  */
19
19
 
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path'
23
23
 
24
24
  import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
25
25
  import { collectSiteContent } from './content-collector.js'
26
- import { processCollections, writeCollectionFiles } from './collection-processor.js'
26
+ import { processQueries, writeQueryFiles } from './query-processor.js'
27
27
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
28
28
  import { processAdvancedAssets } from './advanced-processors.js'
29
29
  import {
@@ -60,7 +60,7 @@ import {
60
60
  * ⇒ The rule stands on its own: we do not know which consumers
61
61
  * re-derive, so we always ship enough for the ones that do.
62
62
  * - `data/<collection>.json` (+ per-record files for `deferred:`
63
- * collections) — same shape `processCollections` produces today.
63
+ * collections) — same shape `processQueries` produces today.
64
64
  * - `assets/<media>` — processed images / video posters / PDF
65
65
  * thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
66
66
  *
@@ -129,7 +129,7 @@ export async function buildSiteData({
129
129
  let siteContent = await collectSiteContent(resolvedSiteRoot, { foundationPath, dropUnpublished: true, base: basePath, strict: true })
130
130
 
131
131
  // 2. Compile content collections (file-based markdown/yaml/json).
132
- // `writeCollectionFiles` lands them under `<siteRoot>/public/data/`;
132
+ // `writeQueryFiles` lands them under `<siteRoot>/public/data/`;
133
133
  // in the vite plugin path that's fine because vite copies
134
134
  // `public/` into `dist/` at build time. The link-mode pipeline
135
135
  // has no vite, so we mirror that copy ourselves into
@@ -137,17 +137,14 @@ export async function buildSiteData({
137
137
  // publish time. (This named `uniweb deploy::collectDataFiles` until
138
138
  // 2026-08-18; no such function has existed for some time.) Same output bytes, same paths, just
139
139
  // without the vite intermediary.
140
- if (siteContent.config?.collections) {
141
- const collectionsBase = siteContent.config?.paths?.collections
142
- ? resolve(resolvedSiteRoot, siteContent.config.paths.collections)
143
- : null
144
- const collections = await processCollections(
140
+ if (siteContent.config?.queries) {
141
+ const byQuery = await processQueries(
145
142
  resolvedSiteRoot,
146
- siteContent.config.collections,
147
- collectionsBase,
143
+ siteContent.config.queries,
144
+ siteContent.config?.paths?.entities,
148
145
  basePath
149
146
  )
150
- await writeCollectionFiles(resolvedSiteRoot, collections, siteContent.config.collections)
147
+ await writeQueryFiles(resolvedSiteRoot, byQuery, siteContent.config.queries)
151
148
 
152
149
  const publicDataDir = join(resolvedSiteRoot, 'public', DATA_DIR)
153
150
  const distDataDir = join(resolvedDistDir, DATA_DIR)
@@ -412,7 +412,7 @@ export async function defineSiteConfig(options = {}) {
412
412
  const allowed = ['..']
413
413
  const parentDir = resolve(siteRoot, '..')
414
414
  const paths = siteConfig.paths || {}
415
- for (const key of ['pages', 'layout', 'collections']) {
415
+ for (const key of ['pages', 'layout', 'entities']) {
416
416
  if (paths[key]) {
417
417
  const resolved = resolve(siteRoot, paths[key])
418
418
  if (!resolved.startsWith(parentDir)) {
@@ -24,7 +24,8 @@
24
24
  */
25
25
 
26
26
  import { readFile, readdir, stat } from 'node:fs/promises'
27
- import { resolveCollectionsConfig, toConfigCollections } from './collections-config.js'
27
+ import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
28
+ import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
28
29
  import { join, parse, resolve, sep } from 'node:path'
29
30
  import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
30
31
  import yaml from 'js-yaml'
@@ -578,13 +579,9 @@ function resolveDisplayTitle(declaredTitle, segment, sections) {
578
579
  return extractH1(sections[0]?.content) || prettifySlug(segment)
579
580
  }
580
581
 
581
- function parseNumericPrefix(filename) {
582
- const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
583
- if (match) {
584
- return { prefix: match[1], name: match[2] || match[1] }
585
- }
586
- return { prefix: null, name: filename }
587
- }
582
+ // The rule now lives in `utils/numeric-prefix.js` — `records.yml` needs the
583
+ // identical one, and a records reader cannot import this module without closing
584
+ // a cycle. Imported at the top; still re-exported below for existing callers.
588
585
 
589
586
  /**
590
587
  * Compare filenames for sorting by numeric prefix.
@@ -593,23 +590,7 @@ function parseNumericPrefix(filename) {
593
590
  function compareFilenames(a, b) {
594
591
  const nameA = isChildSection(parse(a).name) ? stripAtPrefix(parse(a).name) : parse(a).name
595
592
  const nameB = isChildSection(parse(b).name) ? stripAtPrefix(parse(b).name) : parse(b).name
596
- const { prefix: prefixA } = parseNumericPrefix(nameA)
597
- const { prefix: prefixB } = parseNumericPrefix(nameB)
598
-
599
- if (!prefixA && !prefixB) return a.localeCompare(b)
600
- if (!prefixA) return 1
601
- if (!prefixB) return -1
602
-
603
- const partsA = prefixA.split('.').map(Number)
604
- const partsB = prefixB.split('.').map(Number)
605
-
606
- for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
607
- const numA = partsA[i] ?? 0
608
- const numB = partsB[i] ?? 0
609
- if (numA !== numB) return numA - numB
610
- }
611
-
612
- return 0
593
+ return compareByNumericPrefix(nameA, nameB)
613
594
  }
614
595
 
615
596
  /**
@@ -839,12 +820,12 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
839
820
  const insets = extractInsets(proseMirrorContent)
840
821
 
841
822
  // Support 'data:' shorthand for collection fetch
842
- // data: team → fetch: { collection: team }
843
- // data: [team, articles] → fetch: { collection: team } (first item, others via inheritData)
823
+ // data: team → fetch: { query: team }
824
+ // data: [team, articles] → fetch: { query: team } (first item, others via inheritData)
844
825
  let resolvedFetch = fetch
845
826
  if (!fetch && data) {
846
- const collectionName = Array.isArray(data) ? data[0] : data
847
- resolvedFetch = { collection: collectionName }
827
+ const queryName = Array.isArray(data) ? data[0] : data
828
+ resolvedFetch = { query: queryName }
848
829
  }
849
830
 
850
831
  // Stable ID for scroll targeting: frontmatter id > filename-derived > null
@@ -1426,10 +1407,10 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1426
1407
 
1427
1408
  // Data fetching
1428
1409
  // Support 'data:' shorthand at page level
1429
- // data: team → fetch: { collection: team }
1410
+ // data: team → fetch: { query: team }
1430
1411
  fetch: parseFetchConfig(
1431
1412
  pageConfig.fetch || (pageConfig.data
1432
- ? { collection: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
1413
+ ? { query: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
1433
1414
  : undefined)
1434
1415
  ),
1435
1416
 
@@ -2173,16 +2154,20 @@ export async function collectSiteContent(sitePath, options = {}) {
2173
2154
  // Read site config and raw theme config
2174
2155
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2175
2156
 
2176
- // Collections are declared in TWO files — `site.yml::collections` and
2177
- // `collections/collections.yml`, the latter winning per key — and resolving
2178
- // them is one question with one answer. This used to read `site.yml` alone
2179
- // while the sync lane merged both, so a collection declared only in
2180
- // `collections.yml` was invisible here: never compiled, `data: <name>`
2181
- // delivering nothing, while sync pushed it fine.
2182
- const collections = toConfigCollections(
2183
- (await resolveCollectionsConfig(sitePath, { siteYml: siteConfig })).declarations
2157
+ // Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,
2158
+ // the latter winning per key — and resolving them is one question with one
2159
+ // answer. The site build used to read `site.yml` alone while the sync lane
2160
+ // merged both, so a declaration in the second file was invisible here: never
2161
+ // compiled, `data: <name>` delivering nothing, while sync pushed it fine.
2162
+ //
2163
+ // `config.queries` — framework's own payload key, framework's own readers.
2164
+ // The backend's projector never emits it (their measurement: 17 `config` keys,
2165
+ // not this one), and hosting renders with framework's code. There was nobody to
2166
+ // coordinate with, which is exactly why it had no excuse to stay wrong.
2167
+ const byQuery = toConfigQueries(
2168
+ (await resolveQueriesConfig(sitePath, { siteYml: siteConfig })).declarations
2184
2169
  )
2185
- if (collections) siteConfig.collections = collections
2170
+ if (byQuery) siteConfig.queries = byQuery
2186
2171
 
2187
2172
  // Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
2188
2173
  // config so every consumer reads one value. Prerender sets website.basePath
@@ -2411,6 +2396,16 @@ export async function collectSiteContent(sitePath, options = {}) {
2411
2396
  // consumer and never ships in a payload (the visitor runtime is
2412
2397
  // list-unaware; the sync lane reads site.yml directly, not this output).
2413
2398
  const { publishLanguages: _publishLanguages, ...runtimeSiteConfig } = siteConfig
2399
+ // ⛔ `$`-prefixed keys are the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2400
+ // `$backend`, `$services`, `$secrets` — and this payload is a PUBLISHED artifact
2401
+ // that a visitor can fetch. They have no runtime reader (nothing in core, runtime
2402
+ // or kit reads a `config.$*` key), so this removes noise for four of them and a
2403
+ // real disclosure for the fifth: `$secrets` carries no values, but its entries
2404
+ // name every secret the site has, and an inventory of credential names is not
2405
+ // something an `export` should publish.
2406
+ for (const key of Object.keys(runtimeSiteConfig)) {
2407
+ if (key.startsWith('$')) delete runtimeSiteConfig[key]
2408
+ }
2414
2409
 
2415
2410
  return {
2416
2411
  config: {