@uniweb/build 0.57.0 → 0.58.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.
@@ -48,7 +48,7 @@ import { join, resolve } from 'node:path'
48
48
 
49
49
  import { resolveQueriesConfig } from './queries-config.js'
50
50
  import { readEntityFile } from './entity-source.js'
51
- import { readRecordsConfig, resolveFolder, RECORDS_YML_RELPATH } from '../site/records-config.js'
51
+ import { readRecordsConfig, resolveFolder } from '../site/records-config.js'
52
52
  import {
53
53
  readEntityPool,
54
54
  groupPoolBySchema,
@@ -80,17 +80,22 @@ const SKIP_KEYS = new Set([
80
80
  '$unit',
81
81
  '$meta',
82
82
  '$body',
83
- // The draft flag (`site/record-draft.js`) — framework's, never a Model field.
83
+ // The draft flag (`site/record-draft.js`) — framework's, never a Model field. It
84
+ // travels as the entity's `$disabled` (see the document below).
84
85
  'draft',
85
86
  ])
86
87
 
87
88
  // Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
88
89
  // field data; the sigil-exclusivity invariant guarantees this) at every level,
89
- // so a back-filled `$uuid` doesn't change the hash. `$children` is the exception:
90
- // it is STRUCTURAL content (a self-nesting record's subtree, e.g. site-content's
91
- // nested pages/sections), so it is KEPT and recursed into — otherwise a nesting
92
- // change would be invisible to "send only changed". Flat records carry no
93
- // `$children`, so this is a no-op for the collection lane.
90
+ // so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
91
+ // they are content rather than identity:
92
+ // - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
93
+ // site-content's nested pages/sections), so it is KEPT and recursed into —
94
+ // otherwise a nesting change would be invisible to "send only changed". Flat
95
+ // records carry no `$children`, so this is a no-op for the collection lane.
96
+ // - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
97
+ // is KEPT so that drafting or un-drafting a record with nothing else changed is
98
+ // still sent; stripped, "send only changed" would never send the toggle.
94
99
  function stripSigils(value) {
95
100
  if (Array.isArray(value)) return value.map(stripSigils)
96
101
  if (value && typeof value === 'object') {
@@ -123,9 +128,9 @@ function stripSigils(value) {
123
128
  const out = {}
124
129
  for (const [k, v] of Object.entries(value)) {
125
130
  if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
126
- // `$children` (a self-nesting subtree) is structural CONTENT, not an identity
127
- // sigil — kept and recursed into, so a nesting change stays visible.
128
- if (k === '$children') {
131
+ // `$children` (a self-nesting subtree) and `$disabled` (a delivery state) are
132
+ // CONTENT, not identity sigils — kept, so a change to either stays visible.
133
+ if (k === '$children' || k === '$disabled') {
129
134
  out[k] = stripSigils(v)
130
135
  continue
131
136
  }
@@ -338,13 +343,19 @@ export function recordsToEntities({
338
343
  )
339
344
  }
340
345
 
341
- // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, then each
342
- // populated section in declared order (the brief always present as the card).
343
- // `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on its side.
346
+ // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, `$disabled?`,
347
+ // then each populated section in declared order (the brief always present as the
348
+ // card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
349
+ // its side.
344
350
  const document = {}
345
351
  if (uuid) document.$uuid = uuid
346
352
  document.$id = id
347
353
  document.$model = declaration.name
354
+ // ⭐ A DRAFT IS SENT AS A DISABLED ENTITY: it stays in the folder and is never
355
+ // publicly delivered. Only `true` travels. An absent key means enabled [Diego,
356
+ // 2026-09-21], so a record whose `draft: true` is removed is sent without the key
357
+ // and is delivered again.
358
+ if (isDraftRecord(record, `${label}/${slug}`)) document.$disabled = true
348
359
  document[briefName] = sectionData[briefName] || {}
349
360
  for (const [secName] of recordSections) {
350
361
  if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
@@ -532,7 +543,7 @@ function resolveDeclaration(schema, modelName) {
532
543
  * PURE assembly (no hashing, no emit), so it composes with other entity sources
533
544
  * (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
534
545
  * backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
535
- * unresolvable EXPLICIT Model, an invalid `records.yml`, or a duplicate
546
+ * unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
536
547
  * ($model, $id) within the submission.
537
548
  *
538
549
  * @param {string} siteRoot - directory containing site.yml
@@ -556,7 +567,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
556
567
  // ⭐ EVERY FILE IN `records/` IS A RECORD, AND EVERY RECORD IS PUSHED (ruled
557
568
  // 2026-09-21 [Diego]). Placing a file in the directory is what makes it one —
558
569
  // the file-side counterpart of placing a ref in the backend's folder — so nothing
559
- // lists it and nothing leaves it out. `records.yml` only sorts records into
570
+ // lists it and nothing leaves it out. `records/folder.yml` only sorts records into
560
571
  // sub-folders. A file named with a leading `_` is not read, so it is not a record.
561
572
  //
562
573
  // ⛔ A PUSHED RECORD IS NOT LIVE. It sits in the site's folder on the backend and
@@ -575,11 +586,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
575
586
  // one query"), and a record no query read was never pushed. Records are walked
576
587
  // once, by the schema their folder declares.
577
588
  const pool = await readEntityPool(siteRoot)
578
- const recordsCfg = await readRecordsConfig(siteRoot)
589
+ const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
579
590
  if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
580
591
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
581
592
  if (folder.errors.length) {
582
- throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
593
+ throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
583
594
  }
584
595
  const warnings = [...pool.errors, ...folder.warnings]
585
596
 
@@ -682,7 +693,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
682
693
  (alternative
683
694
  ? ` If you meant \`${pool.dir}/${dirs[0]}/\` (${alternative}) organised by ` +
684
695
  `\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
685
- `org scope. Organise records in records.yml, not on disk.`
696
+ `org scope. Organise records in records/folder.yml, not on disk.`
686
697
  : '')
687
698
  )
688
699
  }
@@ -717,8 +728,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
717
728
  const index = []
718
729
  // pool id (the FILE) → the records it produced, so the folder places records.
719
730
  const producedBy = new Map()
720
- // `draft: true` records found on the way — the push is refused if there are any.
721
- const drafts = []
722
731
  // Schemas (as written) whose Model resolved to nothing — soft-skipped.
723
732
  const unresolved = new Set()
724
733
  // The sync response is keyed per ($model, $id), so the pair must be unique
@@ -751,10 +760,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
751
760
  warnings.push(`${pooled.relPath}: a record without a slug was skipped`)
752
761
  continue
753
762
  }
754
- if (isDraftRecord(r.data, pooled.relPath)) {
755
- drafts.push(r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
756
- continue
757
- }
763
+ // A draft is a record like any other, placed in the folder; the mapper sends it
764
+ // disabled. Checked here too so that a malformed `draft:`, or the retired
765
+ // `published: false`, is refused naming the FILE rather than the record.
766
+ isDraftRecord(r.data, r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
758
767
  const rec = { ...r.data, slug: r.slug }
759
768
  if (r.body !== undefined) rec.$body = r.body
760
769
  // ⭐ THE RECORD'S IDENTITY IS ITS POSITION IN THE DIRECTORY — `<dirs>/<slug>`,
@@ -828,27 +837,14 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
828
837
  // document → authoring shape (variant A): unwrap localized fields, route
829
838
  // the content body field to the md body, drop the brief record `$uuid`.
830
839
  declaration,
840
+ // Sent disabled, so the back-fill can check that the backend kept it that way.
841
+ draft: e.document.$disabled === true,
831
842
  })
832
843
  }
833
844
  entities.push(...mappedOut.entities)
834
845
  warnings.push(...mappedOut.warnings)
835
846
  }
836
847
 
837
- // ⛔ A DRAFT IS REFUSED, NOT PUSHED AND NOT SKIPPED. It is a record that stays in the
838
- // folder but is not delivered — which a backend holds as the entity's `disabled`, a
839
- // state this producer cannot send yet. Pushed without it, the backend would serve the
840
- // draft once the site is published; skipped, it would leave the folder — and a site
841
- // whose records were all drafts would send no folder at all, leaving the backend's
842
- // live. So the push stops and says which records, until the flag can travel.
843
- if (drafts.length) {
844
- throw new Error(
845
- `uwx/records: a push cannot mark a record as a draft yet, and the backend would serve it ` +
846
- `once the site is published —\n ${drafts.join('\n ')}\n` +
847
- ' Push them as records by removing `draft: true`, or keep them off the backend by ' +
848
- 'starting their names with `_`.'
849
- )
850
- }
851
-
852
848
  // Queries whose schema resolved to nothing — not pushed as entities. The composite
853
849
  // deploy delivers these statically (the "data ball") instead, so the caller can
854
850
  // route them there.
@@ -26,6 +26,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
27
 
28
28
  import { toDataSchemaDeclaration } from './data-schema.js'
29
+ import { checkFoundationName } from '../foundation-name.js'
29
30
 
30
31
  const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
31
32
  const DATA_SCHEMA = '@uniweb/data-schema'
@@ -148,8 +149,14 @@ function wrapEntities(entities, exporter, exportedAt) {
148
149
  // optional `digest` (sha256:…) is the foundation's content fingerprint; the
149
150
  // backend stores it opaque and returns it on the foundation-latest read.
150
151
  function buildInfo(self, org, digest, runtime) {
151
- // Scope a bare foundation name (`src` -> `@acme/src`); leave an already-scoped name.
152
+ // Scope a bare foundation name (`marketing` -> `@acme/marketing`); leave an
153
+ // already-scoped name.
152
154
  const name = org && !String(self.name).startsWith('@') ? `@${org}/${self.name}` : self.name
155
+ // ⛔ The producer's own guard. `uniweb register` asks for a name before it gets
156
+ // here; this refuses for any other caller, so nothing registers as `@org/src`
157
+ // (`foundation-name.js`).
158
+ const problem = checkFoundationName(name)
159
+ if (problem) throw new Error(`buildRegistryPackage: ${problem}`)
153
160
  const info = { name, version: self.version, role: self.role || 'foundation' }
154
161
  if (self.description !== undefined) info.description = self.description
155
162
  if (digest) info.digest = digest
@@ -291,9 +291,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
291
291
  recordEntities: col.entities,
292
292
  ...(sourceLocale ? { sourceLocale } : {}),
293
293
  // ⭐ Every record in the directory, at the top of the folder or in the
294
- // sub-folder `records.yml` places it in — and NOTHING when there is no folder to
294
+ // sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
295
295
  // state. ⛔ Gated here as well as by `declared`, because the builder sends any
296
- // non-empty tree: a `records.yml` whose folders hold only records that resolved
296
+ // non-empty tree: a `folder.yml` whose folders hold only records that resolved
297
297
  // no schema is a tree of empty branches, and sending it would replace the
298
298
  // backend's folder with them (measured on the `dynamic` template, 2026-09-21).
299
299
  folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],