@crombolo/transfer 0.0.0-stage → 0.10.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/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 +331 -0
- package/src/index.ts +10 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/package.ts +84 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +387 -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/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/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
|
+
}
|
package/src/package.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A written bundle as the npm package every bundle is: a `package.json` whose
|
|
3
|
+
* `crombolo` field declares the payload, and the payload under `content/`.
|
|
4
|
+
* `docs/bundles.md`.
|
|
5
|
+
*
|
|
6
|
+
* The payload is `writeBundle`'s output unchanged, so its manifest, integrity
|
|
7
|
+
* hash and sections mean what they always did. The package is what gives it a
|
|
8
|
+
* name, a version and a core it was written for.
|
|
9
|
+
*/
|
|
10
|
+
import type { BundleFile } from './write.ts'
|
|
11
|
+
|
|
12
|
+
/** The directory inside a package that holds the `bundle.json` payload. */
|
|
13
|
+
export const PAYLOAD_DIR = 'content'
|
|
14
|
+
|
|
15
|
+
/** The npm keyword a bundle publishes so the marketplace can find it. */
|
|
16
|
+
export const BUNDLE_KEYWORD = 'crombolo-bundle'
|
|
17
|
+
|
|
18
|
+
export interface BundlePackageInfo {
|
|
19
|
+
/** The npm package name. */
|
|
20
|
+
name: string
|
|
21
|
+
version: string
|
|
22
|
+
/** `crombolo.id`. */
|
|
23
|
+
id: string
|
|
24
|
+
/** `crombolo.name`, what a person calls it. */
|
|
25
|
+
label: string
|
|
26
|
+
description?: string
|
|
27
|
+
/** A package that is never published: an environment's export. */
|
|
28
|
+
private?: boolean
|
|
29
|
+
/** The range on `@crombolo/crombolo` it is written for. */
|
|
30
|
+
peerRange: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/
|
|
34
|
+
|
|
35
|
+
/** Lowercase words joined by hyphens, from whatever someone named a thing. */
|
|
36
|
+
const slug = (text: string): string =>
|
|
37
|
+
text
|
|
38
|
+
.normalize('NFKD')
|
|
39
|
+
.replace(/[̀-ͯ]/g, '')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
42
|
+
.replace(/^-+|-+$/g, '')
|
|
43
|
+
|
|
44
|
+
/** A name npm would accept. */
|
|
45
|
+
export function isPackageName(name: string): boolean {
|
|
46
|
+
return NPM_NAME.test(name) && name.length <= 214
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A bundle id, 2–39 characters, derived from a name: what an unset definition defaults to. */
|
|
50
|
+
export function bundleIdFor(text: string): string {
|
|
51
|
+
let id = slug(text).replace(/^[^a-z]+/, '')
|
|
52
|
+
if (id.length < 2) id = `bundle-${id}`.replace(/-$/, '')
|
|
53
|
+
return id.slice(0, 39).replace(/-+$/, '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An unscoped package name derived from a name. */
|
|
57
|
+
export function packageNameFor(text: string): string {
|
|
58
|
+
return bundleIdFor(text)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `package.json` a bundle package is written with. */
|
|
62
|
+
export function bundlePackageJson(info: BundlePackageInfo): Record<string, unknown> {
|
|
63
|
+
return {
|
|
64
|
+
name: info.name,
|
|
65
|
+
version: info.version,
|
|
66
|
+
...(info.description ? { description: info.description } : {}),
|
|
67
|
+
...(info.private ? { private: true } : { keywords: [BUNDLE_KEYWORD] }),
|
|
68
|
+
files: [PAYLOAD_DIR],
|
|
69
|
+
peerDependencies: { '@crombolo/crombolo': info.peerRange },
|
|
70
|
+
crombolo: { id: info.id, name: info.label, content: PAYLOAD_DIR },
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A payload's files, placed into a package under `content/` beside its `package.json`. */
|
|
75
|
+
export function writeBundlePackage(
|
|
76
|
+
payload: readonly BundleFile[],
|
|
77
|
+
info: BundlePackageInfo,
|
|
78
|
+
): BundleFile[] {
|
|
79
|
+
if (!isPackageName(info.name)) throw new Error(`"${info.name}" is not a package name npm accepts`)
|
|
80
|
+
return [
|
|
81
|
+
{ path: 'package.json', text: `${JSON.stringify(bundlePackageJson(info), null, 2)}\n` },
|
|
82
|
+
...payload.map((file) => ({ ...file, path: `${PAYLOAD_DIR}/${file.path}` })),
|
|
83
|
+
]
|
|
84
|
+
}
|