@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.
- package/package.json +8 -8
- package/src/foundation-name.js +63 -0
- package/src/i18n/records.js +1 -1
- package/src/index.js +8 -0
- package/src/prerender.js +1 -1
- package/src/schema.js +67 -10
- package/src/site/content-collector.js +1 -1
- package/src/site/entity-pool.js +58 -10
- package/src/site/plugin.js +9 -7
- package/src/site/queries-config.js +2 -2
- package/src/site/query-processor.js +8 -7
- package/src/site/records-config.js +73 -32
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/backfill.js +22 -2
- package/src/uwx/entity-source.js +1 -1
- package/src/uwx/folder.js +3 -3
- package/src/uwx/index.js +4 -3
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +27 -25
- package/src/uwx/records.js +35 -39
- package/src/uwx/registry-package.js +8 -1
- package/src/uwx/sync-package.js +2 -2
package/src/uwx/records.js
CHANGED
|
@@ -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
|
|
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.
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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)
|
|
127
|
-
//
|
|
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`,
|
|
342
|
-
// populated section in declared order (the brief always present as the
|
|
343
|
-
// `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
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: ${
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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 (`
|
|
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
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 ?? []) : [],
|