@uniweb/build 0.14.16 → 0.14.18
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 +3 -3
- package/src/resolve-data-schema.js +15 -0
- package/src/site/data-ball.js +122 -0
- package/src/site/index.js +1 -0
- package/src/uwx/collections.js +12 -4
- package/src/uwx/data-schema.js +4 -3
- package/src/uwx/site-project.js +1 -0
- package/src/uwx/site.js +5 -0
- package/src/uwx/sync-package.js +122 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.18",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -63,9 +63,9 @@
|
|
|
63
63
|
"@uniweb/theming": "0.1.4"
|
|
64
64
|
},
|
|
65
65
|
"optionalDependencies": {
|
|
66
|
-
"@uniweb/content-reader": "1.1.12",
|
|
67
66
|
"@uniweb/runtime": "0.8.20",
|
|
68
|
-
"@uniweb/schemas": "0.2.3"
|
|
67
|
+
"@uniweb/schemas": "0.2.3",
|
|
68
|
+
"@uniweb/content-reader": "1.1.12"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -409,6 +409,21 @@ function normalizeSection(section, ref, path, briefState) {
|
|
|
409
409
|
if (section.nestable) out.nestable = true
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
// `append_only` — a multi whose records are insert-only: the backend accepts
|
|
413
|
+
// appends but refuses edits or deletes of existing items, so the section is
|
|
414
|
+
// tamper-evident (activity logs, submissions, audit trails). Carried into the IR
|
|
415
|
+
// verbatim for the submission lowering to emit as the model's `append_only`.
|
|
416
|
+
// Like `nestable`, only a `multi` section can be append-only.
|
|
417
|
+
if (section.append_only !== undefined) {
|
|
418
|
+
if (typeof section.append_only !== 'boolean') {
|
|
419
|
+
throw new Error(`Data schema '${ref}': section '${path}' 'append_only' must be a boolean.`)
|
|
420
|
+
}
|
|
421
|
+
if (section.append_only && kind !== 'multi') {
|
|
422
|
+
throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but kind '${kind}' — only a 'multi' section can be append-only.`)
|
|
423
|
+
}
|
|
424
|
+
if (section.append_only) out.append_only = true
|
|
425
|
+
}
|
|
426
|
+
|
|
412
427
|
return out
|
|
413
428
|
}
|
|
414
429
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data ball — the static-delivery half of a composite `uniweb deploy`. A site's
|
|
3
|
+
* collections partition by schema presence: a collection that resolves a data schema
|
|
4
|
+
* syncs as folder entities; a SCHEMA-LESS collection has no entity model, so its built
|
|
5
|
+
* `dist/data/<name>.json` (cascade + any `deferred:` per-record files) is delivered
|
|
6
|
+
* statically. This bundles that schema-less subset of `dist/data/**` plus the whole
|
|
7
|
+
* `dist/_search/**` index into one JSON doc the deploy uploads as a single
|
|
8
|
+
* content-addressed asset; the backend unwraps it into the `/data/*` + `/_search/*`
|
|
9
|
+
* bytes the gateway serves.
|
|
10
|
+
*
|
|
11
|
+
* { data: { "<relpath-under-data>": <json> }, // schema-less collections only
|
|
12
|
+
* search: { "<relpath-under-_search>": <json> } } // the whole (baked) index
|
|
13
|
+
*
|
|
14
|
+
* Search is NOT filtered: the index is baked over all content (the live/baked seam —
|
|
15
|
+
* schema-backed lists are served live from entities, but their search entries are baked
|
|
16
|
+
* here until the next deploy).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
21
|
+
import { join, relative, sep } from 'node:path'
|
|
22
|
+
import { isLocalAssetPath } from './assets.js'
|
|
23
|
+
|
|
24
|
+
// Walk a dist subdir for *.json → { "<posix-relpath>": <parsedJson> }. Unparseable
|
|
25
|
+
// files are skipped (build-emitted data is always valid JSON; this just stays safe).
|
|
26
|
+
async function readJsonTree(dir) {
|
|
27
|
+
if (!existsSync(dir)) return {}
|
|
28
|
+
const out = {}
|
|
29
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true })
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
32
|
+
const full = join(entry.parentPath || entry.path, entry.name)
|
|
33
|
+
const rel = relative(dir, full).split(sep).join('/')
|
|
34
|
+
try {
|
|
35
|
+
out[rel] = JSON.parse(await readFile(full, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
// not valid JSON — skip
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The collection a `dist/data` relpath belongs to: the first path segment, minus a
|
|
44
|
+
// trailing `.json`. `articles.json` → `articles`; `articles/hello.json` → `articles`.
|
|
45
|
+
function collectionOf(relPath) {
|
|
46
|
+
const first = relPath.split('/')[0]
|
|
47
|
+
return first.endsWith('.json') ? first.slice(0, -5) : first
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Assemble the static-data ball from a built site's dist/.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} distDir - the site's built dist/ directory
|
|
54
|
+
* @param {string[]} [schemalessNames] - collection names with no data schema (from
|
|
55
|
+
* `emitSyncPackages(...).schemaless`); only these contribute `data`.
|
|
56
|
+
* @returns {Promise<{ data: Object, search: Object }|null>} null when there is nothing
|
|
57
|
+
* to deliver (no schema-less data AND no search index).
|
|
58
|
+
*/
|
|
59
|
+
export async function assembleDataBall(distDir, schemalessNames = []) {
|
|
60
|
+
const schemaless = new Set(schemalessNames)
|
|
61
|
+
const allData = await readJsonTree(join(distDir, 'data'))
|
|
62
|
+
const data = {}
|
|
63
|
+
for (const [relPath, value] of Object.entries(allData)) {
|
|
64
|
+
if (schemaless.has(collectionOf(relPath))) data[relPath] = value
|
|
65
|
+
}
|
|
66
|
+
const search = await readJsonTree(join(distDir, '_search'))
|
|
67
|
+
if (Object.keys(data).length === 0 && Object.keys(search).length === 0) return null
|
|
68
|
+
return { data, search }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- local media in the ball -------------------------------------------------
|
|
72
|
+
// Schema-less collection data rides in the ball, so a local image in a schema-less
|
|
73
|
+
// record (e.g. a note's `image: /images/x.png`) needs the same upload + serve-URL
|
|
74
|
+
// rewrite the entity content gets (emitSyncPackages' `assetRewrite`) — otherwise the
|
|
75
|
+
// served `/data/<name>.json` keeps a dangling local path. The deploy collects the
|
|
76
|
+
// ball's refs, uploads them on the SAME asset lane, then rewrites the ball before
|
|
77
|
+
// uploading it. The backend serves a `serve_url` in the ball identically to one in an
|
|
78
|
+
// entity (it unwraps the ball verbatim), so this is purely producer-side.
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Site-root local asset refs anywhere in the ball (`/images/x.png`, `/collections/...`).
|
|
82
|
+
* Built `dist/data` refs are already site-root (the collection processor copied
|
|
83
|
+
* co-located assets to `public/collections/**`), so only `/`-prefixed refs are collected.
|
|
84
|
+
* @param {{data:object,search:object}|null} ball
|
|
85
|
+
* @returns {string[]} deduped refs to upload
|
|
86
|
+
*/
|
|
87
|
+
export function collectBallAssets(ball) {
|
|
88
|
+
const refs = new Set()
|
|
89
|
+
const walk = (n) => {
|
|
90
|
+
if (typeof n === 'string') {
|
|
91
|
+
if (isLocalAssetPath(n) && n.startsWith('/')) refs.add(n)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(n)) { for (const x of n) walk(x); return }
|
|
95
|
+
if (n && typeof n === 'object') for (const v of Object.values(n)) walk(v)
|
|
96
|
+
}
|
|
97
|
+
walk(ball)
|
|
98
|
+
return [...refs]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rewrite the ball: replace every local ref the map covers with its serve URL. Pure —
|
|
103
|
+
* returns a NEW ball (the input is reused elsewhere). A ref the map omits (upload
|
|
104
|
+
* failed/skipped) is left untouched — never a broken URL.
|
|
105
|
+
* @param {{data:object,search:object}|null} ball
|
|
106
|
+
* @param {Record<string,string>} map - ref → serve URL
|
|
107
|
+
* @returns {{data:object,search:object}|null} a new ball, or the input when there's nothing to do
|
|
108
|
+
*/
|
|
109
|
+
export function rewriteBallAssets(ball, map) {
|
|
110
|
+
if (!ball || !map || Object.keys(map).length === 0) return ball
|
|
111
|
+
const walk = (n) => {
|
|
112
|
+
if (typeof n === 'string') return map[n] || n
|
|
113
|
+
if (Array.isArray(n)) return n.map(walk)
|
|
114
|
+
if (n && typeof n === 'object') {
|
|
115
|
+
const out = {}
|
|
116
|
+
for (const [k, v] of Object.entries(n)) out[k] = walk(v)
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
return n
|
|
120
|
+
}
|
|
121
|
+
return walk(ball)
|
|
122
|
+
}
|
package/src/site/index.js
CHANGED
package/src/uwx/collections.js
CHANGED
|
@@ -422,17 +422,20 @@ async function loadSourceRecords(siteRoot, decl) {
|
|
|
422
422
|
* `@uniweb/data-schema` declaration (or null). The verb wires this to the
|
|
423
423
|
* backend's Model-read route. Without it, the local foundation is required.
|
|
424
424
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
425
|
-
* @returns {Promise<{ entities: object[], index: object[], warnings: string[], mappedCount: number }>}
|
|
425
|
+
* @returns {Promise<{ entities: object[], index: object[], warnings: string[], schemaless: Array<{name: string}>, mappedCount: number }>}
|
|
426
|
+
* `schemaless` lists collections that resolved no data schema (the convention-
|
|
427
|
+
* default soft-skip) — not synced as entities; the composite deploy delivers
|
|
428
|
+
* them statically via the data ball.
|
|
426
429
|
*/
|
|
427
430
|
export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
428
431
|
// Merged collections config (collections.yml over site.yml::collections). Reused
|
|
429
432
|
// from the caller when provided (sync-package shares it with the folder builder).
|
|
430
433
|
const colConfig = opts.collectionsConfig || (await resolveCollectionsConfig(siteRoot))
|
|
431
434
|
if (!colConfig.folderSync) {
|
|
432
|
-
return { entities: [], index: [], warnings: [], mappedCount: 0, colConfig }
|
|
435
|
+
return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
|
|
433
436
|
}
|
|
434
437
|
const mapped = syncableCollections(colConfig.declarations)
|
|
435
|
-
if (mapped.length === 0) return { entities: [], index: [], warnings: [], mappedCount: 0, colConfig }
|
|
438
|
+
if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
|
|
436
439
|
|
|
437
440
|
// A Model declaration comes from a LOCAL foundation (offline) or, for a
|
|
438
441
|
// non-local Model, from the injected async `resolveModel(name)` — the verb wires
|
|
@@ -474,6 +477,10 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
474
477
|
const entities = []
|
|
475
478
|
const index = []
|
|
476
479
|
const warnings = []
|
|
480
|
+
// Collections that resolved no data schema (the convention-default soft-skip
|
|
481
|
+
// below) — not synced as folder entities. The composite deploy delivers these
|
|
482
|
+
// statically (the "data ball") instead, so the caller can route them there.
|
|
483
|
+
const schemaless = []
|
|
477
484
|
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
478
485
|
// within one submission (two collections on the same Model could otherwise
|
|
479
486
|
// reuse a slug).
|
|
@@ -489,6 +496,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
489
496
|
warnings.push(
|
|
490
497
|
`${name}: no data schema "${modelName}" (subfolder-name default) — not synced`
|
|
491
498
|
)
|
|
499
|
+
schemaless.push({ name })
|
|
492
500
|
continue
|
|
493
501
|
}
|
|
494
502
|
throw new Error(
|
|
@@ -577,7 +585,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
577
585
|
warnings.push(...mappedOut.warnings)
|
|
578
586
|
}
|
|
579
587
|
|
|
580
|
-
return { entities, index, warnings, mappedCount: mapped.length, colConfig }
|
|
588
|
+
return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig }
|
|
581
589
|
}
|
|
582
590
|
|
|
583
591
|
/**
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -135,14 +135,15 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
|
|
|
135
135
|
// Lower one section to its declaration body (the caller keys it by name; a nested
|
|
136
136
|
// section gets a `type: section` marker prepended in lowerField). `kind: multi` →
|
|
137
137
|
// `multiple: true`; `binder` is derived (no marker — it falls out of "all fields
|
|
138
|
-
// are type: section"); `nestable` → `self_nesting`;
|
|
139
|
-
// `constraints` pass through as a
|
|
140
|
-
// ordered `fields:` namespace.
|
|
138
|
+
// are type: section"); `nestable` → `self_nesting`; `append_only` (insert-only
|
|
139
|
+
// records) passes through; authored cross-cutting `constraints` pass through as a
|
|
140
|
+
// bare array. Leaves and nested sections share one ordered `fields:` namespace.
|
|
141
141
|
function lowerSection(def, resolve, optResolve) {
|
|
142
142
|
const out = {}
|
|
143
143
|
if ((def.kind || 'single') === 'multi') out.multiple = true
|
|
144
144
|
if (def.brief === true) out.brief = true
|
|
145
145
|
if (def.nestable) out.self_nesting = true
|
|
146
|
+
if (def.append_only) out.append_only = true
|
|
146
147
|
|
|
147
148
|
const fields = {}
|
|
148
149
|
for (const [key, rawField] of Object.entries(def.fields || {})) {
|
package/src/uwx/site-project.js
CHANGED
package/src/uwx/site.js
CHANGED
|
@@ -571,6 +571,11 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
571
571
|
setIf(info, 'search', siteYml.search)
|
|
572
572
|
setIf(info, 'paths', siteYml.paths)
|
|
573
573
|
setIf(info, 'data', siteYml.data ?? siteYml.fetch)
|
|
574
|
+
// `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
|
|
575
|
+
// bound when the site is hosted as a composite. Authored config that round-trips
|
|
576
|
+
// verbatim/opaque (like `foundation`), so a pull→edit→push preserves it; it is
|
|
577
|
+
// deployment-LOCAL (not portable across deployments). (uwx-format.md → info.app.)
|
|
578
|
+
setIf(info, 'app', siteYml.app)
|
|
574
579
|
// `template: true` designates this site as a clonable SITE-TEMPLATE: on push the
|
|
575
580
|
// backend applies a clonability designation to this site-content entity (it is
|
|
576
581
|
// NOT a registry artifact). Verbatim; absent → a normal (non-template) site.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -23,14 +23,15 @@ import { buildCollectionEntities, entityContentHash } from './collections.js'
|
|
|
23
23
|
import { buildFolderEntity } from './folder.js'
|
|
24
24
|
import { siteProjectToDocument } from './site.js'
|
|
25
25
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
26
|
+
import { isLocalAssetPath } from '../site/assets.js'
|
|
26
27
|
|
|
27
28
|
const SITE_MODEL_NAME = '@uniweb/site-content'
|
|
28
29
|
const SITE_ENTITY_KEY = 'site-content'
|
|
29
30
|
|
|
30
31
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
31
32
|
|
|
32
|
-
function emitLane(entities, exporter, exportedAt) {
|
|
33
|
-
const models = [...new Set(entities.map((e) => e.model))]
|
|
33
|
+
function emitLane(entities, exporter, exportedAt, extraModels = []) {
|
|
34
|
+
const models = [...new Set([...entities.map((e) => e.model), ...extraModels])]
|
|
34
35
|
const buffer = emitEntitySyncPackage({
|
|
35
36
|
entities,
|
|
36
37
|
modelsRequired: models.map((name) => ({ name_at_export: name })),
|
|
@@ -40,6 +41,68 @@ function emitLane(entities, exporter, exportedAt) {
|
|
|
40
41
|
return { buffer, entityCount: entities.length, models }
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// Collect the Models referenced by the folder's `ref` leaves (`entry.model`), walking
|
|
45
|
+
// the contents/$children tree. The folder is built from the FULL record set, so it
|
|
46
|
+
// references every record's Model — including records the send-only-changed filter
|
|
47
|
+
// drops from THIS package (a re-push where the folder changed but the records didn't).
|
|
48
|
+
// The backend requires every referenced Model declared in modelsRequired, so these must
|
|
49
|
+
// ride even when their record entities don't.
|
|
50
|
+
function collectReferencedModels(node, acc) {
|
|
51
|
+
if (!node || typeof node !== 'object') return acc
|
|
52
|
+
if (node.entry && typeof node.entry.model === 'string') acc.add(node.entry.model)
|
|
53
|
+
for (const key of ['$children', 'contents']) {
|
|
54
|
+
if (Array.isArray(node[key])) for (const child of node[key]) collectReferencedModels(child, acc)
|
|
55
|
+
}
|
|
56
|
+
return acc
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- local-media over push (Slice 5) ----------------------------------------
|
|
60
|
+
// A site's content references local media by the author's original path
|
|
61
|
+
// (`/images/hero.png`, `./hero.png`); the backend stores media content-addressed
|
|
62
|
+
// and serves it by URL. The deploy uploads the local files and swaps the refs for
|
|
63
|
+
// the backend's serve URLs. Both steps walk the produced entity documents with one
|
|
64
|
+
// generic recursion, filtered by `isLocalAssetPath` (a `/`/`./`/`../` prefix PLUS a
|
|
65
|
+
// media extension) — so it covers PM image `src`, `background`/`params`, record
|
|
66
|
+
// media fields, and localized `{locale: doc}` maps uniformly and safely (non-media
|
|
67
|
+
// strings like `/data/x.json` never match). Mirrors the build's `walkDataAssets`.
|
|
68
|
+
|
|
69
|
+
// Invoke visitor(ref) for every local asset path string anywhere in the document.
|
|
70
|
+
function walkEntityAssets(node, visitor) {
|
|
71
|
+
if (typeof node === 'string') {
|
|
72
|
+
if (isLocalAssetPath(node)) visitor(node)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(node)) {
|
|
76
|
+
for (const item of node) walkEntityAssets(item, visitor)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (node && typeof node === 'object') {
|
|
80
|
+
for (const v of Object.values(node)) walkEntityAssets(v, visitor)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// In-place: replace every local-asset-path string the map covers with its serve
|
|
85
|
+
// URL. A ref the map omits (upload failed/skipped) is left untouched — never a
|
|
86
|
+
// broken URL. Returns the (mutated) node.
|
|
87
|
+
function rewriteEntityAssets(node, map) {
|
|
88
|
+
if (Array.isArray(node)) {
|
|
89
|
+
for (let i = 0; i < node.length; i++) {
|
|
90
|
+
const v = node[i]
|
|
91
|
+
if (typeof v === 'string') { if (map[v]) node[i] = map[v] }
|
|
92
|
+
else rewriteEntityAssets(v, map)
|
|
93
|
+
}
|
|
94
|
+
return node
|
|
95
|
+
}
|
|
96
|
+
if (node && typeof node === 'object') {
|
|
97
|
+
for (const key of Object.keys(node)) {
|
|
98
|
+
const v = node[key]
|
|
99
|
+
if (typeof v === 'string') { if (map[v]) node[key] = map[v] }
|
|
100
|
+
else rewriteEntityAssets(v, map)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return node
|
|
104
|
+
}
|
|
105
|
+
|
|
43
106
|
/**
|
|
44
107
|
* Build the two sync packages for a site.
|
|
45
108
|
*
|
|
@@ -51,11 +114,22 @@ function emitLane(entities, exporter, exportedAt) {
|
|
|
51
114
|
* @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
|
|
52
115
|
* @param {boolean} [opts.sendAll] - bypass the prior-hash filter
|
|
53
116
|
* @param {boolean} [opts.includeSite] - include the site-content lane (default true)
|
|
117
|
+
* @param {object} [opts.injectInfo] - deploy-derived `info.*` to stamp on the
|
|
118
|
+
* site-content document (e.g. `{ data_bundle }`, the static-data ball URL);
|
|
119
|
+
* wire-only — never authored in site.yml, never projected back on pull.
|
|
120
|
+
* @param {Object<string,string>} [opts.assetRewrite] - map of local asset ref →
|
|
121
|
+
* backend serve URL; rewrites the entities' media refs before push (the
|
|
122
|
+
* deploy's 2nd emit). Absent → no rewrite (the f225 sync path is unchanged).
|
|
54
123
|
* @param {object} [opts.exporter] @param {string} [opts.exportedAt]
|
|
55
124
|
* @returns {Promise<{
|
|
56
125
|
* siteContent: { buffer, entityCount, index, models }|null,
|
|
57
126
|
* collections: { buffer, entityCount, index, models }|null,
|
|
58
|
-
* hashes: Object<string,string>, warnings: string[], skipped: number
|
|
127
|
+
* hashes: Object<string,string>, warnings: string[], skipped: number,
|
|
128
|
+
* schemaless: Array<{name: string}>, localAssets: string[] }>}
|
|
129
|
+
* `schemaless` lists collections that resolved no data schema (soft-skipped from
|
|
130
|
+
* the sync) — the composite deploy delivers these statically via the data ball.
|
|
131
|
+
* `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
|
|
132
|
+
* must upload + rewrite to serve URLs; co-located refs are warned and skipped.
|
|
59
133
|
* Each lane is null when it has nothing to push. The collections `index` keeps a
|
|
60
134
|
* leading `{ kind: 'folder' }` placeholder (submission position 0 → the folder
|
|
61
135
|
* entity) so record back-fill stays positionally aligned; the folder itself has no
|
|
@@ -84,6 +158,45 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
84
158
|
})
|
|
85
159
|
|
|
86
160
|
const siteDoc = includeSite ? await siteProjectToDocument(siteRoot, { sourceLocale }) : null
|
|
161
|
+
// Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
|
|
162
|
+
// stamped here — NOT authored in site.yml, so they ride the wire but never project
|
|
163
|
+
// back on pull (the `info.assets` precedent). They are part of the hashed content,
|
|
164
|
+
// so a changed bundle URL correctly re-fires the site-content lane.
|
|
165
|
+
if (siteDoc && opts.injectInfo && typeof opts.injectInfo === 'object') {
|
|
166
|
+
siteDoc.info = { ...siteDoc.info, ...opts.injectInfo }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Local-media over push (Slice 5). `assetRewrite` ({ '/images/x.png': serveUrl })
|
|
170
|
+
// is supplied by the deploy's SECOND emit, after it has uploaded the files the
|
|
171
|
+
// FIRST emit surfaced in `localAssets`. It is absent on the collect emit and on
|
|
172
|
+
// every non-deploy caller, so the f225 sync path is byte-identical without it.
|
|
173
|
+
const assetRewrite =
|
|
174
|
+
opts.assetRewrite && typeof opts.assetRewrite === 'object' ? opts.assetRewrite : null
|
|
175
|
+
if (assetRewrite) {
|
|
176
|
+
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite)
|
|
177
|
+
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite)
|
|
178
|
+
}
|
|
179
|
+
// Collect the site-root local refs the deploy must upload (`/images/x.png`).
|
|
180
|
+
// Co-located refs (`./x`, `../x`) need the source `.md` location to resolve — the
|
|
181
|
+
// entity doesn't carry it — so warn once each and skip (v1: use a site-root path).
|
|
182
|
+
const localAssetSet = new Set()
|
|
183
|
+
const colocatedSeen = new Set()
|
|
184
|
+
const collectFrom = (doc) =>
|
|
185
|
+
doc &&
|
|
186
|
+
walkEntityAssets(doc, (ref) => {
|
|
187
|
+
if (ref.startsWith('/')) localAssetSet.add(ref)
|
|
188
|
+
else if (!colocatedSeen.has(ref)) {
|
|
189
|
+
colocatedSeen.add(ref)
|
|
190
|
+
warnings.push(
|
|
191
|
+
`local-media: co-located asset "${ref}" is not uploaded on the composite deploy — ` +
|
|
192
|
+
`use a site-root path (e.g. /images/${ref.replace(/^[./]+/, '')})`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
collectFrom(siteDoc)
|
|
197
|
+
for (const e of col.entities) collectFrom(e.document)
|
|
198
|
+
const localAssets = [...localAssetSet]
|
|
199
|
+
|
|
87
200
|
const siteEntity = siteDoc
|
|
88
201
|
? { id: siteDoc.$id, model: siteDoc.$model, file: 'entities/site-content.json', document: siteDoc }
|
|
89
202
|
: null
|
|
@@ -114,7 +227,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
114
227
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
115
228
|
const entities = [folder, ...changedRecords.map((r) => r.entity)]
|
|
116
229
|
const index = [{ kind: 'folder' }, ...changedRecords.map((r) => r.index)]
|
|
117
|
-
|
|
230
|
+
// The folder references every record's Model via `entry.model` — including records
|
|
231
|
+
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
232
|
+
// folder that references an undeclared Model).
|
|
233
|
+
const referencedModels = [...collectReferencedModels(folder.document, new Set())]
|
|
234
|
+
collections = { ...emitLane(entities, exporter, exportedAt, referencedModels), index }
|
|
118
235
|
}
|
|
119
236
|
|
|
120
237
|
// --- site-content lane -------------------------------------------------------
|
|
@@ -128,5 +245,5 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
128
245
|
// the content lane by its presence/absence.
|
|
129
246
|
const siteContentUuid = siteDoc?.$uuid
|
|
130
247
|
|
|
131
|
-
return { siteContent, collections, siteContentUuid, hashes, warnings, skipped }
|
|
248
|
+
return { siteContent, collections, siteContentUuid, hashes, warnings, skipped, schemaless: col.schemaless, localAssets }
|
|
132
249
|
}
|