@crombolo/transfer 0.0.0-stage → 0.9.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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/check.ts +552 -0
- package/src/export.ts +316 -0
- package/src/import.ts +327 -0
- package/src/index.ts +9 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +385 -0
- package/src/write.ts +220 -0
- package/README.md +0 -3
package/src/load.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle on disk → `ContentSet`, validated.
|
|
3
|
+
*
|
|
4
|
+
* Reading is strict about shape and about integrity, and says which file was
|
|
5
|
+
* wrong, because everything downstream assumes a bundle is well formed: the
|
|
6
|
+
* check reports on what it would do, and by then a malformed node file would be
|
|
7
|
+
* a crash rather than a finding.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
BUNDLE_FORMAT_VERSION,
|
|
13
|
+
BUNDLE_SECTIONS,
|
|
14
|
+
type BundleKind,
|
|
15
|
+
type BundleManifest,
|
|
16
|
+
type BundleNode,
|
|
17
|
+
type BundleProblem,
|
|
18
|
+
type BundleSection,
|
|
19
|
+
type BundleValue,
|
|
20
|
+
type BundleVariant,
|
|
21
|
+
type ContentSet,
|
|
22
|
+
} from './model.ts'
|
|
23
|
+
import { blobPath, bundleIntegrity, MANIFEST_FILE, nodePath, sectionOf } from './write.ts'
|
|
24
|
+
|
|
25
|
+
export interface LoadedBundle {
|
|
26
|
+
set: ContentSet | undefined
|
|
27
|
+
problems: BundleProblem[]
|
|
28
|
+
/**
|
|
29
|
+
* The carried section files, by their path inside the bundle, as paths on
|
|
30
|
+
* disk — the structure and views an install applies. Empty for a
|
|
31
|
+
* content-only bundle, which is every version 1 bundle.
|
|
32
|
+
*/
|
|
33
|
+
files: Map<string, string>
|
|
34
|
+
/**
|
|
35
|
+
* The media files the bundle carries, by store key, as paths on disk.
|
|
36
|
+
*
|
|
37
|
+
* Paths rather than bytes: a bundle of a media library is as large as the
|
|
38
|
+
* library, and nothing here needs it in memory at once.
|
|
39
|
+
*/
|
|
40
|
+
blobs: Map<string, string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KINDS: readonly BundleKind[] = ['document', 'media', 'element', 'blueprint']
|
|
44
|
+
|
|
45
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
46
|
+
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
47
|
+
|
|
48
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
|
49
|
+
const nullableStr = (v: unknown): string | null | undefined =>
|
|
50
|
+
v === null ? null : typeof v === 'string' ? v : undefined
|
|
51
|
+
|
|
52
|
+
function parseVariant(
|
|
53
|
+
raw: unknown,
|
|
54
|
+
file: string,
|
|
55
|
+
problems: BundleProblem[],
|
|
56
|
+
): BundleVariant | undefined {
|
|
57
|
+
if (!isRecord(raw)) {
|
|
58
|
+
problems.push({ file, message: 'a variant is not an object' })
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
const name = str(raw.name)
|
|
62
|
+
const culture = nullableStr(raw.culture)
|
|
63
|
+
const segment = nullableStr(raw.segment)
|
|
64
|
+
if (name === undefined || culture === undefined || segment === undefined) {
|
|
65
|
+
problems.push({ file, message: 'a variant needs culture, segment and name' })
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
return { culture, segment, name, published: raw.published === true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseValue(
|
|
72
|
+
raw: unknown,
|
|
73
|
+
file: string,
|
|
74
|
+
problems: BundleProblem[],
|
|
75
|
+
): BundleValue | undefined {
|
|
76
|
+
if (!isRecord(raw)) {
|
|
77
|
+
problems.push({ file, message: 'a value is not an object' })
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
const property = str(raw.property)
|
|
81
|
+
const editor = str(raw.editor)
|
|
82
|
+
const culture = nullableStr(raw.culture)
|
|
83
|
+
const segment = nullableStr(raw.segment)
|
|
84
|
+
if (
|
|
85
|
+
property === undefined ||
|
|
86
|
+
editor === undefined ||
|
|
87
|
+
culture === undefined ||
|
|
88
|
+
segment === undefined
|
|
89
|
+
) {
|
|
90
|
+
problems.push({ file, message: 'a value needs property, editor, culture and segment' })
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
const references = Array.isArray(raw.references)
|
|
94
|
+
? raw.references.flatMap((r) => (typeof r === 'string' ? [r] : []))
|
|
95
|
+
: undefined
|
|
96
|
+
return { property, culture, segment, editor, value: raw.value, references }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function parseNode(
|
|
100
|
+
text: string,
|
|
101
|
+
file: string,
|
|
102
|
+
problems: BundleProblem[],
|
|
103
|
+
): BundleNode | undefined {
|
|
104
|
+
let raw: unknown
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(text)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
problems.push({ file, message: `not JSON: ${(error as Error).message}` })
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
if (!isRecord(raw)) {
|
|
112
|
+
problems.push({ file, message: 'not an object' })
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
const key = str(raw.key)
|
|
116
|
+
const kind = str(raw.kind) as BundleKind | undefined
|
|
117
|
+
const contentType = isRecord(raw.contentType) ? raw.contentType : undefined
|
|
118
|
+
const typeKey = str(contentType?.key)
|
|
119
|
+
const typeAlias = str(contentType?.alias)
|
|
120
|
+
if (!key) problems.push({ file, message: 'no key' })
|
|
121
|
+
if (!kind || !KINDS.includes(kind))
|
|
122
|
+
problems.push({ file, message: `kind must be one of ${KINDS.join(', ')}` })
|
|
123
|
+
if (!typeAlias) problems.push({ file, message: 'no contentType.alias' })
|
|
124
|
+
const parent = nullableStr(raw.parent)
|
|
125
|
+
if (parent === undefined) problems.push({ file, message: 'parent must be a key or null' })
|
|
126
|
+
if (!key || !kind || !KINDS.includes(kind) || !typeAlias || parent === undefined) return undefined
|
|
127
|
+
|
|
128
|
+
const variants = Array.isArray(raw.variants)
|
|
129
|
+
? raw.variants.flatMap((v) => {
|
|
130
|
+
const parsed = parseVariant(v, file, problems)
|
|
131
|
+
return parsed ? [parsed] : []
|
|
132
|
+
})
|
|
133
|
+
: []
|
|
134
|
+
const values = Array.isArray(raw.values)
|
|
135
|
+
? raw.values.flatMap((v) => {
|
|
136
|
+
const parsed = parseValue(v, file, problems)
|
|
137
|
+
return parsed ? [parsed] : []
|
|
138
|
+
})
|
|
139
|
+
: []
|
|
140
|
+
if (variants.length === 0) problems.push({ file, message: 'no variants: a node needs a name' })
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
key,
|
|
144
|
+
kind,
|
|
145
|
+
contentType: { key: typeKey ?? '', alias: typeAlias },
|
|
146
|
+
parent,
|
|
147
|
+
sortOrder: typeof raw.sortOrder === 'number' ? raw.sortOrder : 0,
|
|
148
|
+
template: nullableStr(raw.template) ?? null,
|
|
149
|
+
variants,
|
|
150
|
+
values,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseManifest(text: string, problems: BundleProblem[]): BundleManifest | undefined {
|
|
155
|
+
let raw: unknown
|
|
156
|
+
try {
|
|
157
|
+
raw = JSON.parse(text)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
problems.push({ file: MANIFEST_FILE, message: `not JSON: ${(error as Error).message}` })
|
|
160
|
+
return undefined
|
|
161
|
+
}
|
|
162
|
+
if (!isRecord(raw)) {
|
|
163
|
+
problems.push({ file: MANIFEST_FILE, message: 'not an object' })
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
const formatVersion = typeof raw.formatVersion === 'number' ? raw.formatVersion : undefined
|
|
167
|
+
if (formatVersion === undefined) {
|
|
168
|
+
problems.push({ file: MANIFEST_FILE, message: 'no formatVersion' })
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
if (formatVersion > BUNDLE_FORMAT_VERSION) {
|
|
172
|
+
problems.push({
|
|
173
|
+
file: MANIFEST_FILE,
|
|
174
|
+
message: `format ${formatVersion} is newer than this version understands (${BUNDLE_FORMAT_VERSION}) — upgrade crombolo to import it`,
|
|
175
|
+
})
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
const provenance = isRecord(raw.provenance) ? raw.provenance : {}
|
|
179
|
+
const selector = isRecord(raw.selector) ? raw.selector : {}
|
|
180
|
+
const dependencies = isRecord(raw.dependencies) ? raw.dependencies : {}
|
|
181
|
+
const schema = isRecord(dependencies.schema) ? dependencies.schema : {}
|
|
182
|
+
const keys = (v: unknown): string[] =>
|
|
183
|
+
Array.isArray(v) ? v.flatMap((k) => (typeof k === 'string' ? [k] : [])) : []
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
formatVersion,
|
|
187
|
+
id: str(raw.id) ?? '',
|
|
188
|
+
...(str(raw.label) ? { label: str(raw.label) as string } : {}),
|
|
189
|
+
createdAt: str(raw.createdAt) ?? '',
|
|
190
|
+
createdBy: str(raw.createdBy) ?? '',
|
|
191
|
+
integrity: str(raw.integrity) ?? '',
|
|
192
|
+
snapshot: raw.snapshot === 'drafts' ? 'drafts' : 'published',
|
|
193
|
+
provenance: {
|
|
194
|
+
siteName: str(provenance.siteName) ?? '',
|
|
195
|
+
schemaVersion: str(provenance.schemaVersion) ?? '',
|
|
196
|
+
schemaRevision: str(provenance.schemaRevision) ?? '',
|
|
197
|
+
schemaHash: nullableStr(provenance.schemaHash) ?? null,
|
|
198
|
+
},
|
|
199
|
+
selector: {
|
|
200
|
+
roots: keys(selector.roots),
|
|
201
|
+
descendants: selector.descendants !== false,
|
|
202
|
+
asGiven: keys(selector.asGiven),
|
|
203
|
+
},
|
|
204
|
+
counts: isRecord(raw.counts) ? (raw.counts as BundleManifest['counts']) : {},
|
|
205
|
+
dependencies: {
|
|
206
|
+
carried: keys(dependencies.carried),
|
|
207
|
+
expected: Array.isArray(dependencies.expected)
|
|
208
|
+
? dependencies.expected.flatMap((e) =>
|
|
209
|
+
isRecord(e) && typeof e.key === 'string'
|
|
210
|
+
? [
|
|
211
|
+
{
|
|
212
|
+
key: e.key,
|
|
213
|
+
objectType: str(e.objectType) ?? '',
|
|
214
|
+
name: str(e.name) ?? '',
|
|
215
|
+
why: str(e.why) ?? '',
|
|
216
|
+
},
|
|
217
|
+
]
|
|
218
|
+
: [],
|
|
219
|
+
)
|
|
220
|
+
: [],
|
|
221
|
+
schema: {
|
|
222
|
+
contentTypes: Array.isArray(schema.contentTypes)
|
|
223
|
+
? schema.contentTypes.flatMap((t) =>
|
|
224
|
+
isRecord(t) && typeof t.alias === 'string'
|
|
225
|
+
? [{ key: str(t.key) ?? '', alias: t.alias }]
|
|
226
|
+
: [],
|
|
227
|
+
)
|
|
228
|
+
: [],
|
|
229
|
+
components: keys(schema.components),
|
|
230
|
+
languages: keys(schema.languages),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
blobs: Array.isArray(raw.blobs)
|
|
234
|
+
? raw.blobs.flatMap((b) =>
|
|
235
|
+
isRecord(b) && typeof b.key === 'string'
|
|
236
|
+
? [
|
|
237
|
+
{
|
|
238
|
+
key: b.key,
|
|
239
|
+
node: str(b.node) ?? '',
|
|
240
|
+
etag: nullableStr(b.etag) ?? null,
|
|
241
|
+
size: typeof b.size === 'number' ? b.size : null,
|
|
242
|
+
included: b.included === true,
|
|
243
|
+
},
|
|
244
|
+
]
|
|
245
|
+
: [],
|
|
246
|
+
)
|
|
247
|
+
: [],
|
|
248
|
+
...(isRecord(raw.carries) ? { carries: parseCarries(raw.carries) } : {}),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The declared sections, dropping any name this version does not know and any
|
|
254
|
+
* path that tries to climb out of the bundle — a manifest is a file that
|
|
255
|
+
* travelled, so what it names is read, never trusted.
|
|
256
|
+
*/
|
|
257
|
+
function parseCarries(raw: Record<string, unknown>): Partial<Record<BundleSection, string[]>> {
|
|
258
|
+
const out: Partial<Record<BundleSection, string[]>> = {}
|
|
259
|
+
for (const section of Object.keys(BUNDLE_SECTIONS) as BundleSection[]) {
|
|
260
|
+
const paths = raw[section]
|
|
261
|
+
if (!Array.isArray(paths)) continue
|
|
262
|
+
const safe = paths.flatMap((path) =>
|
|
263
|
+
typeof path === 'string' && sectionOf(path) === section && !path.split('/').includes('..')
|
|
264
|
+
? [path]
|
|
265
|
+
: [],
|
|
266
|
+
)
|
|
267
|
+
if (safe.length > 0) out[section] = safe.sort()
|
|
268
|
+
}
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Reads a bundle directory. `set` is undefined when it cannot be trusted at all. */
|
|
273
|
+
export function loadBundle(dir: string): LoadedBundle {
|
|
274
|
+
const problems: BundleProblem[] = []
|
|
275
|
+
const blobs = new Map<string, string>()
|
|
276
|
+
const files = new Map<string, string>()
|
|
277
|
+
const manifestFile = join(dir, MANIFEST_FILE)
|
|
278
|
+
if (!existsSync(manifestFile)) {
|
|
279
|
+
problems.push({ file: MANIFEST_FILE, message: `no bundle at ${dir}` })
|
|
280
|
+
return { set: undefined, problems, blobs, files }
|
|
281
|
+
}
|
|
282
|
+
const manifest = parseManifest(readFileSync(manifestFile, 'utf8'), problems)
|
|
283
|
+
if (!manifest) return { set: undefined, problems, blobs, files }
|
|
284
|
+
|
|
285
|
+
const nodesDir = join(dir, 'nodes')
|
|
286
|
+
const nodeFiles = existsSync(nodesDir)
|
|
287
|
+
? readdirSync(nodesDir)
|
|
288
|
+
.filter((name) => name.endsWith('.json'))
|
|
289
|
+
.sort()
|
|
290
|
+
: []
|
|
291
|
+
const read: Array<{ path: string; text?: string; bytes?: Uint8Array }> = nodeFiles.map(
|
|
292
|
+
(name) => ({
|
|
293
|
+
path: `nodes/${name}`,
|
|
294
|
+
text: readFileSync(join(nodesDir, name), 'utf8'),
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
// A blob the manifest says it carries has to be here, and has to be the file
|
|
299
|
+
// the hash was taken over: `included` is a claim, and this is what checks it.
|
|
300
|
+
for (const blob of manifest.blobs) {
|
|
301
|
+
if (!blob.included) continue
|
|
302
|
+
const path = blobPath(blob.key)
|
|
303
|
+
const file = join(dir, path)
|
|
304
|
+
if (!existsSync(file)) {
|
|
305
|
+
problems.push({
|
|
306
|
+
file: path,
|
|
307
|
+
message: 'the manifest says this file is carried, and it is not',
|
|
308
|
+
})
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
blobs.set(blob.key, file)
|
|
312
|
+
read.push({ path, bytes: new Uint8Array(readFileSync(file)) })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Every file the manifest says it carries has to be here, and has to be the
|
|
316
|
+
// file the hash was taken over — the same rule as a blob's `included`.
|
|
317
|
+
for (const [, paths] of Object.entries(manifest.carries ?? {})) {
|
|
318
|
+
for (const path of paths as string[]) {
|
|
319
|
+
const file = join(dir, path)
|
|
320
|
+
if (!existsSync(file)) {
|
|
321
|
+
problems.push({
|
|
322
|
+
file: path,
|
|
323
|
+
message: 'the manifest says this file is carried, and it is not',
|
|
324
|
+
})
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
files.set(path, file)
|
|
328
|
+
read.push({ path, text: readFileSync(file, 'utf8') })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Before shape: a truncated copy produces confusing field errors otherwise.
|
|
333
|
+
const integrity = bundleIntegrity(read)
|
|
334
|
+
if (manifest.integrity && manifest.integrity !== integrity)
|
|
335
|
+
problems.push({
|
|
336
|
+
file: MANIFEST_FILE,
|
|
337
|
+
message: `integrity does not match the node files (${manifest.integrity} vs ${integrity}) — the bundle is incomplete or was edited`,
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const nodes = read.flatMap((file) => {
|
|
341
|
+
// Only `nodes/` holds nodes. Blobs have no text, and a carried section is a
|
|
342
|
+
// TOML or TSX file that has no business being parsed as one.
|
|
343
|
+
if (file.text === undefined || !file.path.startsWith('nodes/')) return []
|
|
344
|
+
const node = parseNode(file.text, file.path, problems)
|
|
345
|
+
if (node && nodePath(node.key) !== file.path)
|
|
346
|
+
problems.push({
|
|
347
|
+
file: file.path,
|
|
348
|
+
message: `holds node ${node.key}, so it should be named after it`,
|
|
349
|
+
})
|
|
350
|
+
return node ? [node] : []
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const seen = new Set<string>()
|
|
354
|
+
for (const node of nodes) {
|
|
355
|
+
if (seen.has(node.key)) problems.push({ file: nodePath(node.key), message: 'appears twice' })
|
|
356
|
+
seen.add(node.key)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { set: { manifest, nodes }, problems, blobs, files }
|
|
360
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundle: content selected out of one environment and reviewable on its way
|
|
3
|
+
* to another. `docs/13-content-transfer.md`.
|
|
4
|
+
*
|
|
5
|
+
* Everything travels by uuid and alias, never by integer id, because the ids
|
|
6
|
+
* differ between environments and the keys do not. A content type is matched by
|
|
7
|
+
* key first — tooling writes keys into `schema/*.toml`, so they are stable
|
|
8
|
+
* across environments — with the alias as the readable fallback.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Bumped when a change to the shapes below stops an older reader understanding a bundle. */
|
|
12
|
+
export const BUNDLE_FORMAT_VERSION = 2
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The version a bundle carrying only content is still written as.
|
|
16
|
+
*
|
|
17
|
+
* A content-only bundle is byte-for-byte what version 1 was, so it keeps saying
|
|
18
|
+
* 1 and an older node can still read it. Only a bundle that carries a section
|
|
19
|
+
* beyond its content needs a reader that knows about `carries`, and that one
|
|
20
|
+
* says 2 — which an older node refuses with a message naming the upgrade,
|
|
21
|
+
* rather than failing an integrity check it cannot explain.
|
|
22
|
+
*/
|
|
23
|
+
export const CONTENT_ONLY_FORMAT_VERSION = 1
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The sections a bundle can carry beyond its content, and the directory each
|
|
27
|
+
* one occupies.
|
|
28
|
+
*
|
|
29
|
+
* Logical names, not the destination's directory names: a site may hold its
|
|
30
|
+
* components anywhere (`componentsDir`), so the artifact names what a file *is*
|
|
31
|
+
* and the importer decides where it goes. A bundle written against one site's
|
|
32
|
+
* layout therefore installs into another's.
|
|
33
|
+
*
|
|
34
|
+
* There is one section for components, not two. Umbraco's split of templates
|
|
35
|
+
* and partial views was a Razor concern (`docs/05-rendering.md`), and a bundle
|
|
36
|
+
* that carried it would push the distinction back into every site that
|
|
37
|
+
* installed one.
|
|
38
|
+
*/
|
|
39
|
+
export const BUNDLE_SECTIONS = {
|
|
40
|
+
schema: 'schema',
|
|
41
|
+
components: 'components',
|
|
42
|
+
styles: 'styles',
|
|
43
|
+
scripts: 'scripts',
|
|
44
|
+
dictionary: 'dictionary',
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
export type BundleSection = keyof typeof BUNDLE_SECTIONS
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The kinds that travel. `member` is deliberately absent: members are personal
|
|
51
|
+
* data, and a bundle is a file that gets copied around and committed.
|
|
52
|
+
*/
|
|
53
|
+
export type BundleKind = 'document' | 'media' | 'element' | 'blueprint'
|
|
54
|
+
|
|
55
|
+
/** Which snapshot of a node's values the bundle carries. */
|
|
56
|
+
export type BundleSnapshot = 'published' | 'drafts'
|
|
57
|
+
|
|
58
|
+
export interface BundleValue {
|
|
59
|
+
property: string
|
|
60
|
+
culture: string | null
|
|
61
|
+
segment: string | null
|
|
62
|
+
/** The editor that wrote it, so the destination can refuse a property whose editor changed. */
|
|
63
|
+
editor: string
|
|
64
|
+
value: unknown
|
|
65
|
+
/** Node keys this value refers to, extracted at export so the importer need not re-parse. */
|
|
66
|
+
references?: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One culture (or the invariant `null`) of a node: its name and whether it was
|
|
71
|
+
* published at the source. Shaped to feed the repository's own variant input.
|
|
72
|
+
*/
|
|
73
|
+
export interface BundleVariant {
|
|
74
|
+
culture: string | null
|
|
75
|
+
segment: string | null
|
|
76
|
+
name: string
|
|
77
|
+
published: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BundleNode {
|
|
81
|
+
key: string
|
|
82
|
+
kind: BundleKind
|
|
83
|
+
contentType: { key: string; alias: string }
|
|
84
|
+
/** The parent's key, or null for a node at the tree root. */
|
|
85
|
+
parent: string | null
|
|
86
|
+
sortOrder: number
|
|
87
|
+
/** A component alias — the path under `components/`, so aliases travel, not keys. */
|
|
88
|
+
template: string | null
|
|
89
|
+
variants: BundleVariant[]
|
|
90
|
+
values: BundleValue[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Something the bundle refers to but does not carry: it must already exist at the destination. */
|
|
94
|
+
export interface BundleDependency {
|
|
95
|
+
key: string
|
|
96
|
+
objectType: string
|
|
97
|
+
name: string
|
|
98
|
+
/** Why it is needed, in words, so a blocking finding can explain itself. */
|
|
99
|
+
why: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A media file the bundle's media nodes name. Blobs travel only when asked for. */
|
|
103
|
+
export interface BundleBlob {
|
|
104
|
+
/** The media store key, as the value names it. */
|
|
105
|
+
key: string
|
|
106
|
+
etag: string | null
|
|
107
|
+
size: number | null
|
|
108
|
+
included: boolean
|
|
109
|
+
/** The media node that names it. */
|
|
110
|
+
node: string
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface BundleDependencies {
|
|
114
|
+
/** Keys carried in the bundle. */
|
|
115
|
+
carried: string[]
|
|
116
|
+
/** Keys that must already exist at the destination. */
|
|
117
|
+
expected: BundleDependency[]
|
|
118
|
+
/** What the destination's schema and views must provide. */
|
|
119
|
+
schema: {
|
|
120
|
+
contentTypes: Array<{ key: string; alias: string }>
|
|
121
|
+
components: string[]
|
|
122
|
+
languages: string[]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a bundle came from. Recorded and printed, never gated on: development
|
|
128
|
+
* runs at revision 0 by design, so refusing a bundle whose revision is higher
|
|
129
|
+
* than the destination's would refuse every transfer out of production. What
|
|
130
|
+
* the destination actually has to satisfy is checked element by element.
|
|
131
|
+
*/
|
|
132
|
+
export interface BundleProvenance {
|
|
133
|
+
schemaVersion: string
|
|
134
|
+
schemaRevision: string
|
|
135
|
+
schemaHash: string | null
|
|
136
|
+
siteName: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface BundleSelector {
|
|
140
|
+
/** The resolved subtree roots. */
|
|
141
|
+
roots: string[]
|
|
142
|
+
descendants: boolean
|
|
143
|
+
/** What was typed on the command line, for readability when a path was given. */
|
|
144
|
+
asGiven: string[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface BundleManifest {
|
|
148
|
+
formatVersion: number
|
|
149
|
+
id: string
|
|
150
|
+
/**
|
|
151
|
+
* What a person calls this bundle — a created package's name, when that is
|
|
152
|
+
* where it came from.
|
|
153
|
+
*
|
|
154
|
+
* It travels with the artifact rather than being supplied at import time, so
|
|
155
|
+
* a downloaded bundle still knows what it is: two files are otherwise
|
|
156
|
+
* distinguishable only by filename, which is the first thing to be lost.
|
|
157
|
+
*/
|
|
158
|
+
label?: string
|
|
159
|
+
createdAt: string
|
|
160
|
+
createdBy: string
|
|
161
|
+
/** `sha256-…` over the node files, so a half-copied bundle is refused rather than imported. */
|
|
162
|
+
integrity: string
|
|
163
|
+
snapshot: BundleSnapshot
|
|
164
|
+
provenance: BundleProvenance
|
|
165
|
+
selector: BundleSelector
|
|
166
|
+
counts: Partial<Record<BundleKind, number>>
|
|
167
|
+
dependencies: BundleDependencies
|
|
168
|
+
blobs: BundleBlob[]
|
|
169
|
+
/**
|
|
170
|
+
* The files this bundle carries beyond its content and media, by section, in
|
|
171
|
+
* path order — the structure and the views that make a bundle installable
|
|
172
|
+
* into a site that has neither.
|
|
173
|
+
*
|
|
174
|
+
* A claim the reader checks, exactly as `blobs.included` is: every path named
|
|
175
|
+
* here has to be present, and the integrity hash covers it. Absent on a
|
|
176
|
+
* content-only bundle, which is what version 1 always was.
|
|
177
|
+
*/
|
|
178
|
+
carries?: Partial<Record<BundleSection, string[]>>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A bundle read into memory: the manifest and its nodes. Named for
|
|
183
|
+
* `SchemaSet` in `@crombolo/schema`, which plays the same part for structure.
|
|
184
|
+
*/
|
|
185
|
+
export interface ContentSet {
|
|
186
|
+
manifest: BundleManifest
|
|
187
|
+
nodes: BundleNode[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A problem with the bundle itself, as opposed to a finding about applying it. */
|
|
191
|
+
export interface BundleProblem {
|
|
192
|
+
file: string
|
|
193
|
+
message: string
|
|
194
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a stored value refers to.
|
|
3
|
+
*
|
|
4
|
+
* `ReferenceRepository` answers the other direction — what refers to *this* node
|
|
5
|
+
* — by scanning for a known key. Exporting needs the outbound direction, from a
|
|
6
|
+
* value to the keys inside it, and nothing knew how to do that.
|
|
7
|
+
*
|
|
8
|
+
* Two spellings travel, as `references.ts` in `@crombolo/data` documents: a
|
|
9
|
+
* picker stores `umb://document/<hex>` or a bare uuid, dashed or not. Element
|
|
10
|
+
* pickers store a bare `Guid[]`, and rich text and block editors hold both
|
|
11
|
+
* inside JSON, so the scan goes through structures and through JSON held in a
|
|
12
|
+
* string.
|
|
13
|
+
*
|
|
14
|
+
* **These are candidates, not references.** An undashed 32-hex run is
|
|
15
|
+
* indistinguishable from any other 32 hex characters, so the caller resolves
|
|
16
|
+
* each against `node.unique_id` and discards what does not exist. Being wrong
|
|
17
|
+
* in this direction is cheap; missing a reference is not.
|
|
18
|
+
*/
|
|
19
|
+
import { keyFromReference } from '@crombolo/core'
|
|
20
|
+
|
|
21
|
+
/** Dashed uuids, and 32-hex runs that are not part of a longer hex string. */
|
|
22
|
+
const DASHED = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
|
23
|
+
const PLAIN = /(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/gi
|
|
24
|
+
|
|
25
|
+
function fromString(text: string, into: Set<string>): void {
|
|
26
|
+
for (const match of text.matchAll(DASHED)) {
|
|
27
|
+
const key = keyFromReference(match[0])
|
|
28
|
+
if (key) into.add(key)
|
|
29
|
+
}
|
|
30
|
+
for (const match of text.matchAll(PLAIN)) {
|
|
31
|
+
const key = keyFromReference(match[0])
|
|
32
|
+
if (key) into.add(key)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function walk(value: unknown, into: Set<string>, depth: number): void {
|
|
37
|
+
// Block editors nest, but not deeply; the guard is against a cycle in a
|
|
38
|
+
// structure we did not build, not against legitimate nesting.
|
|
39
|
+
if (depth > 32) return
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
fromString(value, into)
|
|
42
|
+
// A value is often JSON in a string. Scanning the text already found every
|
|
43
|
+
// key in it, so parsing adds nothing — except where a key is escaped or
|
|
44
|
+
// split across the encoding, which is why the parse is attempted at all.
|
|
45
|
+
if (/^\s*[[{]/.test(value)) {
|
|
46
|
+
try {
|
|
47
|
+
walk(JSON.parse(value), into, depth + 1)
|
|
48
|
+
} catch {
|
|
49
|
+
// Not JSON after all; the text scan above stands.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
for (const item of value) walk(item, into, depth + 1)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === 'object' && value !== null) {
|
|
59
|
+
for (const item of Object.values(value)) walk(item, into, depth + 1)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every key-shaped candidate inside a value, dashed and lowercased. Resolve
|
|
65
|
+
* them against real nodes before treating any of them as a reference.
|
|
66
|
+
*/
|
|
67
|
+
export function referenceCandidates(value: unknown): string[] {
|
|
68
|
+
const found = new Set<string>()
|
|
69
|
+
walk(value, found, 0)
|
|
70
|
+
return [...found].sort()
|
|
71
|
+
}
|