uniweb 0.33.1 → 0.34.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.
@@ -592,7 +592,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
592
592
  *
593
593
  * The `buildLocalizedContent` step is the same call bundle mode makes
594
594
  * post-vite, so multi-locale sites get identical per-locale outputs in
595
- * either mode. Collection translation (`buildLocalizedCollections`)
595
+ * either mode. Collection translation (`buildLocalizedRecords`)
596
596
  * also runs here so deploy ships translated collection JSONs.
597
597
  *
598
598
  * Bug surfaced + fixed by routing deploy through this path: the bundle
@@ -652,28 +652,28 @@ async function buildSiteLink(projectDir, options = {}) {
652
652
  // Collection translations — optional; don't fail the build if
653
653
  // missing. Bundle mode does the same.
654
654
  try {
655
- const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
656
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
655
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
656
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
657
657
  locales: i18nConfig.locales,
658
658
  outputDir: distDir,
659
- collectionsLocalesDir: join(
659
+ recordLocalesDir: join(
660
660
  projectDir,
661
661
  i18nConfig.localesDir,
662
- 'collections'
662
+ 'records'
663
663
  )
664
664
  })
665
- const collectionCount = Object.values(collectionOutputs).reduce(
665
+ const recordCount = Object.values(recordOutputs).reduce(
666
666
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
667
667
  0
668
668
  )
669
- if (collectionCount > 0) {
669
+ if (recordCount > 0) {
670
670
  success(
671
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
671
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
672
672
  )
673
673
  }
674
674
  } catch (err) {
675
675
  if (process.env.UNIWEB_DEBUG)
676
- console.error('Collection translation:', err.message)
676
+ console.error('Record translation:', err.message)
677
677
  }
678
678
  } catch (err) {
679
679
  error(`i18n build failed: ${err.message}`)
@@ -772,33 +772,33 @@ async function buildSite(projectDir, options = {}) {
772
772
 
773
773
  // Translate collections if they exist
774
774
  try {
775
- const { buildLocalizedCollections } = await import('@uniweb/build/i18n')
775
+ const { buildLocalizedRecords } = await import('@uniweb/build/i18n')
776
776
 
777
- const collectionOutputs = await buildLocalizedCollections(projectDir, {
777
+ const recordOutputs = await buildLocalizedRecords(projectDir, {
778
778
  locales: i18nConfig.locales,
779
779
  outputDir: join(projectDir, 'dist'),
780
- collectionsLocalesDir: join(
780
+ recordLocalesDir: join(
781
781
  projectDir,
782
782
  i18nConfig.localesDir,
783
- 'collections'
783
+ 'records'
784
784
  )
785
785
  })
786
786
 
787
787
  // Count collections translated
788
- const collectionCount = Object.values(collectionOutputs).reduce(
788
+ const recordCount = Object.values(recordOutputs).reduce(
789
789
  (sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
790
790
  0
791
791
  )
792
792
 
793
- if (collectionCount > 0) {
793
+ if (recordCount > 0) {
794
794
  success(
795
- `Translated collections for ${Object.keys(collectionOutputs).length} locale(s)`
795
+ `Translated records for ${Object.keys(recordOutputs).length} locale(s)`
796
796
  )
797
797
  }
798
798
  } catch (err) {
799
799
  // Collection translation is optional, don't fail build
800
800
  if (process.env.UNIWEB_DEBUG) {
801
- console.error('Collection translation:', err.message)
801
+ console.error('Record translation:', err.message)
802
802
  }
803
803
  }
804
804
  } catch (err) {
@@ -24,7 +24,7 @@
24
24
  * seed;
25
25
  * 4. install, then delegate the projection to the project-local `uniweb pull` (which
26
26
  * resolves the now-installed project-local `@uniweb/build`; clone forwards
27
- * `--no-collections` to it when set).
27
+ * `--no-records` to it when set).
28
28
  *
29
29
  * Sites are private — authenticate with `uniweb login` first; the session carries
30
30
  * identity + the backend origin. There is no `--foundation` flag: the site carries
@@ -37,7 +37,7 @@
37
37
  * the current workspace when run inside one)
38
38
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
39
39
  * uniweb clone <uuid> --project docs Co-located docs/site
40
- * uniweb clone <uuid> --no-collections Pull pages only; skip collection records
40
+ * uniweb clone <uuid> --no-records Pull pages only; skip records
41
41
  *
42
42
  * Backend: via BackendClient (the site-content pull lane). Origin from
43
43
  * --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
@@ -176,7 +176,7 @@ export async function clone(args = [], deps = {}) {
176
176
  if (!siteUuid) {
177
177
  error('Missing site uuid.')
178
178
  log(
179
- `\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-collections]`
179
+ `\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-records]`
180
180
  )
181
181
  log(
182
182
  `${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`
@@ -184,8 +184,8 @@ export async function clone(args = [], deps = {}) {
184
184
  return { exitCode: 2 }
185
185
  }
186
186
 
187
- const noCollections =
188
- args.includes('--no-collections') || args.includes('--content-only')
187
+ const noRecords =
188
+ args.includes('--no-records') || args.includes('--content-only')
189
189
  const pathFlag = flagValue(args, '--path')
190
190
  const projectFlag = flagValue(args, '--project')
191
191
  const tokenFlag = flagValue(args, '--token')
@@ -386,7 +386,7 @@ export async function clone(args = [], deps = {}) {
386
386
  // bytes on disk, not of what the scaffolder first wrote.
387
387
  recordWritten(
388
388
  siteDir,
389
- ['site.yml', 'theme.yml', 'head.html', 'collections.yml']
389
+ ['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
390
390
  .map((f) => join(siteDir, f))
391
391
  .filter((p) => existsSync(p))
392
392
  )
@@ -394,7 +394,7 @@ export async function clone(args = [], deps = {}) {
394
394
  const pullExtra = []
395
395
  if (explicitBackend) pullExtra.push('--backend', explicitBackend)
396
396
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
397
- if (noCollections) pullExtra.push('--no-collections')
397
+ if (noRecords) pullExtra.push('--no-records')
398
398
 
399
399
  if (deps.skipPull) {
400
400
  note('Skipping pull (test mode).')
@@ -162,8 +162,7 @@ async function contentExport(args) {
162
162
  if (entity.layout_sections?.length)
163
163
  counts.layout_sections = entity.layout_sections.length
164
164
  if (entity.extensions?.length) counts.extensions = entity.extensions.length
165
- if (entity.collections?.length)
166
- counts.collections = entity.collections.length
165
+ if (entity.queries?.length) counts.queries = entity.queries.length
167
166
  }
168
167
 
169
168
  console.log('')
@@ -198,6 +197,6 @@ async function contentExport(args) {
198
197
  )
199
198
  }
200
199
  console.log('')
201
- say.warn('v0 scope: media bytes, collection records, and @-nested section')
200
+ say.warn('v0 scope: media bytes, records, and @-nested section')
202
201
  say.dim('hierarchy are not yet carried (documented).')
203
202
  }
@@ -153,8 +153,8 @@ function loadSiteYml(dir) {
153
153
  /**
154
154
  * Diagnose the compiled-collection output directory.
155
155
  *
156
- * `public/<DATA_DIR>/` holds what the build compiles from `collections/`, and
157
- * nothing else — `collections/` is the only supported way to provide
156
+ * `public/<DATA_DIR>/` holds what the build compiles from `entities/`, and
157
+ * nothing else — `entities/` + `records.yml` is the only supported way to provide
158
158
  * structured data. Two consequences, both checked here:
159
159
  *
160
160
  * 1. **The mapping is a bijection.** Every entry should be backed by a
@@ -548,9 +548,33 @@ export async function checkFormSubmitTarget({ sitePath, siteName, siteYml, issue
548
548
  )
549
549
  }
550
550
 
551
+ /** The bare map in `queries.yml`, or `{}` when there is none. */
552
+ function readQueriesYml(sitePath) {
553
+ try {
554
+ const doc = yaml.load(readFileSync(join(sitePath, 'queries.yml'), 'utf8'))
555
+ return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : {}
556
+ } catch {
557
+ return {}
558
+ }
559
+ }
560
+
551
561
  function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix, fixed }) {
552
562
  const dataDir = join(sitePath, 'public', DATA_DIR)
553
- const declared = new Set(Object.keys(siteYml.collections || {}))
563
+ // ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
564
+ // MATERIALIZATION — one file per named query — so the bijection is with the
565
+ // declared queries, never with the schema folders under `entities/`.
566
+ //
567
+ // ⛔ BOTH HOMES, or this reports every compiled file as an orphan. A site that
568
+ // keeps its queries in `queries.yml` has none in `site.yml`, and reading one
569
+ // file would turn the whole check into a false positive — the loudest possible
570
+ // failure for a check whose job is to find stale output.
571
+ //
572
+ // Read directly rather than through `resolveQueriesConfig`: this pass is
573
+ // synchronous, and it needs the NAMES rather than resolved declarations.
574
+ const declared = new Set([
575
+ ...Object.keys(siteYml.queries || {}),
576
+ ...Object.keys(readQueriesYml(sitePath))
577
+ ])
554
578
 
555
579
  if (existsSync(dataDir)) {
556
580
  // A collection `x` owns `x.json` (the cascade) and `x/` (per-record files
@@ -565,16 +589,16 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
565
589
  .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
566
590
 
567
591
  if (orphans.length > 0) {
568
- const id = 'orphaned-collection-output'
592
+ const id = 'orphaned-query-output'
569
593
  issues.push({
570
594
  id,
571
595
  type: 'warning',
572
596
  site: siteName,
573
- message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared collection`
597
+ message: `${orphans.length} entr${orphans.length === 1 ? 'y' : 'ies'} in public/${DATA_DIR}/ with no declared query`
574
598
  })
575
599
  warn(`[${id}] Stale output in public/${DATA_DIR}/: ${orphans.join(', ')}`)
576
600
  log(
577
- ` No collection in site.yml produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
601
+ ` No query produces ${orphans.length === 1 ? 'it' : 'these'}. ` +
578
602
  `${orphans.length === 1 ? 'It is' : 'They are'} still served and deployed.`
579
603
  )
580
604
  if (shouldFix(id)) {
@@ -614,7 +638,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
614
638
  const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
615
639
  writeFileSync(
616
640
  gitignorePath,
617
- `${body}\n# Compiled collections — generated from collections/\n${rule}\n`
641
+ `${body}\n# Compiled query results — generated from entities/ + queries.yml\n${rule}\n`
618
642
  )
619
643
  fixed(`added ${rule} to ${gitignorePath}`)
620
644
  if (existsSync(dataDir)) {
@@ -891,9 +915,9 @@ export async function doctor(args = []) {
891
915
  }
892
916
 
893
917
  // `public/<DATA_DIR>/` is the build's output directory and nothing else —
894
- // `collections/` is the only supported way to provide structured data. That
895
- // makes the mapping a bijection: every entry there should be backed by a
896
- // declared collection, so anything else is stale, and identifiable.
918
+ // `entities/` + `records.yml` is the only supported way to provide structured
919
+ // data. That makes the mapping a bijection: every entry there should be backed
920
+ // by a declared QUERY, so anything else is stale, and identifiable.
897
921
  //
898
922
  // It matters because the directory is written into the source tree rather
899
923
  // than dist/, so what lands there persists and gets deployed. A collection
@@ -242,13 +242,13 @@ async function loadSiteConfig(siteRoot) {
242
242
  async function runExtract(siteRoot, config, args) {
243
243
  const verbose = args.includes('--verbose') || args.includes('-v')
244
244
  const dryRun = args.includes('--dry-run')
245
- const collectionsOnly =
246
- args.includes('--collections-only') || args.includes('--collections')
247
- const noCollections = args.includes('--no-collections')
245
+ const recordsOnly =
246
+ args.includes('--records-only') || args.includes('--records')
247
+ const noRecords = args.includes('--no-records')
248
248
  // --with-collections is now a no-op (collections are included by default)
249
249
 
250
- // Extract page content (unless --collections-only)
251
- if (!collectionsOnly) {
250
+ // Extract page content (unless --records-only)
251
+ if (!recordsOnly) {
252
252
  log(
253
253
  `\n${colors.cyan}Extracting translatable content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
254
254
  )
@@ -309,43 +309,43 @@ async function runExtract(siteRoot, config, args) {
309
309
  }
310
310
  }
311
311
 
312
- // Extract collection content (by default, skip with --no-collections)
313
- if (!noCollections) {
312
+ // Extract collection content (by default, skip with --no-records)
313
+ if (!noRecords) {
314
314
  log(
315
- `\n${colors.cyan}Extracting collection content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
315
+ `\n${colors.cyan}Extracting record content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
316
316
  )
317
317
 
318
318
  // Check if collections exist
319
319
  const dataDir = join(siteRoot, 'public', DATA_DIR)
320
320
  if (!existsSync(dataDir)) {
321
- if (collectionsOnly) {
322
- error('No collections found. Create collection data in public/data/.')
321
+ if (recordsOnly) {
322
+ error('No records found. Add entities under entities/ and list them in records.yml.')
323
323
  process.exit(1)
324
324
  }
325
- log(`${colors.dim}No collections found in public/data/.${colors.reset}`)
325
+ log(`${colors.dim}No records found in public/data/.${colors.reset}`)
326
326
  return
327
327
  }
328
328
 
329
329
  try {
330
- const { extractCollectionManifest, formatSyncReport } =
330
+ const { extractRecordManifest, formatSyncReport } =
331
331
  await import('@uniweb/build/i18n')
332
332
 
333
- const collectionsManifestPath = join(
333
+ const recordManifestPath = join(
334
334
  siteRoot,
335
335
  config.localesDir,
336
- 'collections',
336
+ 'records',
337
337
  'manifest.json'
338
338
  )
339
- const isUpdate = existsSync(collectionsManifestPath)
339
+ const isUpdate = existsSync(recordManifestPath)
340
340
 
341
- const { manifest, report } = await extractCollectionManifest(siteRoot, {
341
+ const { manifest, report } = await extractRecordManifest(siteRoot, {
342
342
  localesDir: config.localesDir,
343
343
  dryRun
344
344
  })
345
345
 
346
346
  const unitCount = Object.keys(manifest.units).length
347
347
  if (unitCount > 0) {
348
- success(`Extracted ${unitCount} translatable strings from collections`)
348
+ success(`Extracted ${unitCount} translatable strings from records`)
349
349
 
350
350
  if (report && isUpdate) {
351
351
  log('')
@@ -356,18 +356,18 @@ async function runExtract(siteRoot, config, args) {
356
356
  log(`\n${colors.dim}Dry run — no files were modified.${colors.reset}`)
357
357
  } else {
358
358
  log(
359
- `\nManifest written to: ${colors.dim}${config.localesDir}/collections/manifest.json${colors.reset}`
359
+ `\nManifest written to: ${colors.dim}${config.localesDir}/records/manifest.json${colors.reset}`
360
360
  )
361
361
  }
362
362
  } else {
363
363
  log(
364
- `${colors.dim}No translatable content found in collections.${colors.reset}`
364
+ `${colors.dim}No translatable content found in records.${colors.reset}`
365
365
  )
366
366
  }
367
367
  } catch (err) {
368
- error(`Collection extraction failed: ${err.message}`)
368
+ error(`Record extraction failed: ${err.message}`)
369
369
  if (verbose) console.error(err)
370
- if (collectionsOnly) process.exit(1)
370
+ if (recordsOnly) process.exit(1)
371
371
  }
372
372
  }
373
373
  }
@@ -652,7 +652,7 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
652
652
  const allPaths = [
653
653
  ...discovered.pages,
654
654
  ...discovered.pageIds,
655
- ...discovered.collections
655
+ ...discovered.records
656
656
  ]
657
657
 
658
658
  // Check staleness
@@ -988,11 +988,11 @@ async function runAudit(siteRoot, config, args) {
988
988
  * Usage:
989
989
  * uniweb i18n init-freeform es pages/about hero
990
990
  * uniweb i18n init-freeform es page-ids/installation intro
991
- * uniweb i18n init-freeform es collections/articles getting-started
991
+ * uniweb i18n init-freeform es entities/article getting-started
992
992
  */
993
993
  async function runInitFreeform(siteRoot, config, args) {
994
994
  const locale = args[0]
995
- const pathType = args[1] // pages/about, page-ids/installation, collections/articles
995
+ const pathType = args[1] // pages/about, page-ids/installation, entities/article
996
996
  const sectionId = args[2] // hero, intro, getting-started
997
997
 
998
998
  if (!locale || !pathType || !sectionId) {
@@ -1001,7 +1001,7 @@ async function runInitFreeform(siteRoot, config, args) {
1001
1001
  log(' uniweb i18n init-freeform es pages/about hero')
1002
1002
  log(' uniweb i18n init-freeform es page-ids/installation intro')
1003
1003
  log(
1004
- ` uniweb i18n init-freeform es collections/articles getting-started${colors.reset}`
1004
+ ` uniweb i18n init-freeform es entities/article getting-started${colors.reset}`
1005
1005
  )
1006
1006
  process.exit(1)
1007
1007
  }
@@ -1057,15 +1057,29 @@ async function runInitFreeform(siteRoot, config, args) {
1057
1057
  if (sourceContent) break
1058
1058
  }
1059
1059
  }
1060
- } else if (pathType.startsWith('collections/')) {
1061
- // Find item in collection data
1062
- const collectionName = pathType.replace('collections/', '')
1063
- const dataPath = join(
1064
- siteRoot,
1065
- 'public',
1066
- 'data',
1067
- `${collectionName}.json`
1068
- )
1060
+ } else if (pathType.startsWith('entities/')) {
1061
+ // ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
1062
+ // tree is `entities/<schema dirs>/<slug>.md`; this used to take
1063
+ // `collections/<query>` and write a path nothing read — the loader moved
1064
+ // and this did not.
1065
+ //
1066
+ // The record's CONTENT still lives in a query's materialization, so the
1067
+ // query that covers this schema is resolved rather than named.
1068
+ const poolDirs = pathType.replace('entities/', '')
1069
+ const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
1070
+ let queryName = null
1071
+ try {
1072
+ const { declarations } = await resolveQueriesConfig(siteRoot)
1073
+ for (const [name, decl] of Object.entries(declarations || {})) {
1074
+ const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
1075
+ if (dirs && dirs.join('/') === poolDirs) { queryName = name; break }
1076
+ }
1077
+ } catch {
1078
+ /* no resolvable config — fall through to the not-found message below */
1079
+ }
1080
+ const dataPath = queryName
1081
+ ? join(siteRoot, 'public', 'data', `${queryName}.json`)
1082
+ : join(siteRoot, 'public', 'data', '__none__.json')
1069
1083
 
1070
1084
  if (existsSync(dataPath)) {
1071
1085
  const dataRaw = await readFile(dataPath, 'utf-8')
@@ -1607,8 +1621,8 @@ ${colors.bright}Options:${colors.reset}
1607
1621
  --freeform (status/prune) Include free-form translation status
1608
1622
  --json (status) Output as JSON for translation tools
1609
1623
  --by-page (status --missing) Group missing strings by page
1610
- --collections-only (extract/status/audit) Process only collections
1611
- --no-collections (extract/status/audit) Skip collections (pages only)
1624
+ --records-only (extract/status/audit) Process only records
1625
+ --no-records (extract/status/audit) Skip records (pages only)
1612
1626
  --all-stale (update-hash) Update all stale translations at once
1613
1627
 
1614
1628
  ${colors.bright}Configuration:${colors.reset}
@@ -1638,14 +1652,14 @@ ${colors.bright}File Structure:${colors.reset}
1638
1652
  .manifest.json Staleness tracking
1639
1653
  pages/about/hero.md Translated content for /about page, hero section
1640
1654
  page-ids/install/intro.md Translated content by page ID
1641
- collections/articles/getting-started.md
1655
+ entities/article/getting-started.md
1642
1656
 
1643
1657
  ${colors.bright}Examples:${colors.reset}
1644
1658
  ${colors.dim}# Hash-based workflow${colors.reset}
1645
1659
  uniweb i18n extract # Extract all translatable strings
1646
1660
  uniweb i18n extract --dry-run # Preview without writing
1647
1661
  uniweb i18n extract --verbose # Show extracted strings
1648
- uniweb i18n extract --no-collections # Pages only (skip collections)
1662
+ uniweb i18n extract --no-records # Pages only (skip records)
1649
1663
  uniweb i18n generate es fr # Create starter files for Spanish and French
1650
1664
  uniweb i18n generate --empty # Create files with empty values (for translators)
1651
1665
  uniweb i18n generate --force # Overwrite existing locale files
@@ -1658,7 +1672,7 @@ ${colors.bright}Examples:${colors.reset}
1658
1672
  ${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
1659
1673
  uniweb i18n init-freeform es pages/about hero
1660
1674
  uniweb i18n init-freeform es page-ids/installation intro
1661
- uniweb i18n init-freeform es collections/articles getting-started
1675
+ uniweb i18n init-freeform es entities/article getting-started
1662
1676
  uniweb i18n status --freeform # Show free-form translation status
1663
1677
  uniweb i18n update-hash es --all-stale # Update hashes after review
1664
1678
  uniweb i18n move pages/docs/setup pages/getting-started
@@ -70,6 +70,7 @@ import {
70
70
  readSiteIdentity
71
71
  } from '../utils/site-identity.js'
72
72
  import { isNonInteractive, confirm } from '../utils/interactive.js'
73
+ import { guardEmptyRecords } from '../utils/records-guard.js'
73
74
  import { headProvenance } from '../utils/git.js'
74
75
  import {
75
76
  makeModelResolver,
@@ -93,7 +94,7 @@ import {
93
94
  readPaymentRefusal,
94
95
  reportPaymentRefusal
95
96
  } from '../backend/payment-handoff.js'
96
- import { reportSchemalessCollections } from '../utils/schemaless-report.js'
97
+ import { reportSchemalessQueries } from '../utils/schemaless-report.js'
97
98
  import { uploadSiteData } from '../utils/site-data-upload.js'
98
99
 
99
100
  const c = {
@@ -113,7 +114,20 @@ const say = {
113
114
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
114
115
  }
115
116
 
116
- // Origin-relative serve path → clickable absolute URL (self-serve default).
117
+ // Origin-relative serve path → clickable absolute URL.
118
+ //
119
+ // ⭐ THE TWO SHAPES ARE A CONTRACT, NOT AN INCONSISTENCY — ratified 2026-08-29 and
120
+ // documented in the backend's `wire-layer.md` rather than merely observed. A publish
121
+ // returns an ABSOLUTE url when Cloudflare hosts the site (another origin entirely)
122
+ // and an ORIGIN-RELATIVE path when the backend serves it itself, where its own
123
+ // external origin is not reliably self-reportable from behind an ALB.
124
+ //
125
+ // ⇒ So this branch is implementing the contract, not defending against drift. I
126
+ // reported the two shapes as a violation of "finished values only" in collab
127
+ // framework-backend-812b; the backend checked, found the adjacent ruling that
128
+ // explains the relative arm, and ratified both. Do not "fix" it by demanding one
129
+ // shape — the caller's own origin is the missing half on the relative arm, and we
130
+ // are the caller.
117
131
  function absolutizeServeUrl(origin, url) {
118
132
  if (!url || typeof url !== 'string') return null
119
133
  if (/^https?:\/\//.test(url)) return url
@@ -234,15 +248,17 @@ export async function publish(args = []) {
234
248
  }
235
249
  const asOrg = org.asOrg
236
250
 
237
- // Capability handshake (cached). Publish ends in a go-live, so the publish
238
- // lane must be offered.
239
- const config = await client.discover()
240
- if (config?.delivery && config.delivery.publish === false) {
241
- say.err(
242
- `Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`
243
- )
244
- return { exitCode: 1 }
245
- }
251
+ // There is no capability gate here any more, deliberately.
252
+ //
253
+ // This used to read `delivery.publish` from the discovery document and refuse when it
254
+ // was false. It could never fire: the backend sent a literal true for every deployment,
255
+ // so the gate compared a constant against false. The key is now gone on both sides
256
+ // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
257
+ // while implying a capability that was never negotiable.
258
+ //
259
+ // Discovery is not consulted on this path at all. The one leaf the CLI still reads
260
+ // (`delivery.siteSubscriptionRequired`) is read after the site create, where a
261
+ // credential is already in hand.
246
262
 
247
263
  // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
248
264
  // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
@@ -436,6 +452,14 @@ export async function publish(args = []) {
436
452
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
437
453
  const resolveModel = makeModelResolver({ client, offline: false })
438
454
 
455
+ // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
456
+ // destructive — a placeholder file, created meaning to fill it in — so the count
457
+ // is reported and confirmed before anything is sent.
458
+ {
459
+ const guard = await guardEmptyRecords({ siteDir, args, warn: say.warn, note: say.dim })
460
+ if (!guard.ok) return { exitCode: 1 }
461
+ }
462
+
439
463
  // 3. Partition collections by schema presence (a first emit reads `schemaless`
440
464
  // — collections with no data schema, delivered statically via the ball).
441
465
  let probe
@@ -456,7 +480,7 @@ export async function publish(args = []) {
456
480
  // A product decision the author is usually making unknowingly — say it at warn
457
481
  // level, not dim among everything else. See the helper for what the old
458
482
  // message got wrong.
459
- reportSchemalessCollections(probe.schemaless, say)
483
+ reportSchemalessQueries(probe.schemaless, say)
460
484
  const localAssets = probe.localAssets || []
461
485
 
462
486
  // 3a. A clone with no `$uuid` is bound to no backend site, so every cached map
@@ -579,7 +603,7 @@ export async function publish(args = []) {
579
603
  // `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
580
604
  // makes an absent key non-breaking by construction.
581
605
  if (ball) {
582
- say.info('Uploading collection data…')
606
+ say.info('Uploading schema-less record data…')
583
607
  try {
584
608
  const r = await uploadSiteData({
585
609
  apiBase: client.origin,
@@ -595,9 +619,9 @@ export async function publish(args = []) {
595
619
  for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
596
620
  return { exitCode: 1 }
597
621
  }
598
- say.dim(`Collection data : ${r.uploaded.length} file(s) [${r.mode}]`)
622
+ say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
599
623
  } catch (err) {
600
- say.err(`Collection data upload failed: ${err.message}`)
624
+ say.err(`Record data upload failed: ${err.message}`)
601
625
  return { exitCode: 1 }
602
626
  }
603
627
  }