@crombolo/server 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 +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles: the definitions, and the npm package a download builds from one.
|
|
3
|
+
* `docs/bundles.md`.
|
|
4
|
+
*
|
|
5
|
+
* Nothing new is serialized here. Schema travels as the canonical TOML
|
|
6
|
+
* `@crombolo/schema` already writes, content and media as the bundle
|
|
7
|
+
* `@crombolo/transfer` already exports, files as themselves, and dictionary
|
|
8
|
+
* items as the `.udt` the dictionary already exports. The build is composition,
|
|
9
|
+
* which is why a bundle can carry everything the builder UI offers.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import {
|
|
13
|
+
type BuiltPackage,
|
|
14
|
+
type FileSystemPort,
|
|
15
|
+
type PackageDefinition,
|
|
16
|
+
type PackageDefinitionInput,
|
|
17
|
+
type PackagePort,
|
|
18
|
+
type PackageWriteResult,
|
|
19
|
+
packageFileName,
|
|
20
|
+
} from '@crombolo/api-management'
|
|
21
|
+
import { ObjectTypes, type Page } from '@crombolo/core'
|
|
22
|
+
import {
|
|
23
|
+
type Bundle,
|
|
24
|
+
BundleRepository,
|
|
25
|
+
type ComponentFileStore,
|
|
26
|
+
ComponentRepository,
|
|
27
|
+
currentSchemaState,
|
|
28
|
+
type Db,
|
|
29
|
+
DictionaryRepository,
|
|
30
|
+
type NodeRow,
|
|
31
|
+
resolveNodeRef,
|
|
32
|
+
} from '@crombolo/data'
|
|
33
|
+
import {
|
|
34
|
+
exportSchemaSet,
|
|
35
|
+
fileNameFor,
|
|
36
|
+
type SchemaSet,
|
|
37
|
+
writeContentType,
|
|
38
|
+
writeDataType,
|
|
39
|
+
writeLanguages,
|
|
40
|
+
writeMediaType,
|
|
41
|
+
writeSchemaVersion,
|
|
42
|
+
} from '@crombolo/schema'
|
|
43
|
+
import {
|
|
44
|
+
BUNDLE_SECTIONS,
|
|
45
|
+
type BundleBlob,
|
|
46
|
+
type BundleDependency,
|
|
47
|
+
type BundleFile,
|
|
48
|
+
type BundleKind,
|
|
49
|
+
type BundleNode,
|
|
50
|
+
type BundleSection,
|
|
51
|
+
bundleIdFor,
|
|
52
|
+
type ContentSet,
|
|
53
|
+
exportBundle,
|
|
54
|
+
isPackageName,
|
|
55
|
+
packageNameFor,
|
|
56
|
+
writeBundle,
|
|
57
|
+
writeBundlePackage,
|
|
58
|
+
} from '@crombolo/transfer'
|
|
59
|
+
import { VERSION } from '../config.ts'
|
|
60
|
+
import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
|
|
61
|
+
import { readBlobs } from '../media-files.ts'
|
|
62
|
+
import type { MediaStore } from '../media-store.ts'
|
|
63
|
+
import { writeNpmTarball } from '../tar.ts'
|
|
64
|
+
|
|
65
|
+
/** The npm package a definition is built as: what was set on it, or what its name gives. */
|
|
66
|
+
export function bundlePackageOf(row: Bundle): { name: string; version: string } {
|
|
67
|
+
return {
|
|
68
|
+
name: row.packageName ?? packageNameFor(row.name),
|
|
69
|
+
version: row.packageVersion ?? '1.0.0',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The content kinds a bundle's picks can name. */
|
|
74
|
+
const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
75
|
+
|
|
76
|
+
export interface PackagePortOptions {
|
|
77
|
+
siteName: string
|
|
78
|
+
nodeId: string
|
|
79
|
+
marketplaceUrl: string
|
|
80
|
+
/** Reads a template's view, which lives on disk rather than in the row. */
|
|
81
|
+
componentFiles: ComponentFileStore
|
|
82
|
+
mediaStore: MediaStore
|
|
83
|
+
components?: FileSystemPort
|
|
84
|
+
stylesheets?: FileSystemPort
|
|
85
|
+
scripts?: FileSystemPort
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const definition = (row: Bundle): PackageDefinition => ({
|
|
89
|
+
id: row.id,
|
|
90
|
+
name: row.name,
|
|
91
|
+
contentNodeId: row.contentNodeId,
|
|
92
|
+
contentLoadChildNodes: row.contentLoadChildNodes,
|
|
93
|
+
mediaIds: row.mediaIds,
|
|
94
|
+
mediaLoadChildNodes: row.mediaLoadChildNodes,
|
|
95
|
+
elementIds: row.elementIds,
|
|
96
|
+
contentTypes: row.contentTypes,
|
|
97
|
+
mediaTypes: row.mediaTypes,
|
|
98
|
+
dataTypes: row.dataTypes,
|
|
99
|
+
// The wire format is Umbraco's, and it still has two fields. There is one
|
|
100
|
+
// list on this side, so it answers as `templates` and `partialViews` is
|
|
101
|
+
// always empty — the split is a property of the contract, not of the data.
|
|
102
|
+
templates: row.components,
|
|
103
|
+
partialViews: [],
|
|
104
|
+
stylesheets: row.stylesheets,
|
|
105
|
+
scripts: row.scripts,
|
|
106
|
+
languages: row.languages,
|
|
107
|
+
dictionaryItems: row.dictionaryItems,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The contract's two lists, stored as the one this side has.
|
|
112
|
+
*
|
|
113
|
+
* `templates` and `partialViews` are both components; which tree the backoffice
|
|
114
|
+
* picked one from is not something worth keeping.
|
|
115
|
+
*/
|
|
116
|
+
const stored = <T extends PackageDefinitionInput>(input: T) => ({
|
|
117
|
+
...input,
|
|
118
|
+
components: [...input.templates, ...input.partialViews],
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/** Matches a selection against a type's key or its alias, since the UI sends either. */
|
|
122
|
+
const picks = (selected: readonly string[]) => {
|
|
123
|
+
const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
|
|
124
|
+
return (type: { key?: string; alias: string }): boolean =>
|
|
125
|
+
wanted.has(type.alias.toLowerCase()) ||
|
|
126
|
+
(type.key !== undefined && wanted.has(type.key.toLowerCase()))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The selected slice of the site's schema, as the files `schema/` would hold. */
|
|
130
|
+
function schemaEntries(set: SchemaSet, from: PackageDefinition): BundleFile[] {
|
|
131
|
+
const entries: BundleFile[] = []
|
|
132
|
+
const put = (path: string, text: string) =>
|
|
133
|
+
entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
|
|
134
|
+
|
|
135
|
+
const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
|
|
136
|
+
const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
|
|
137
|
+
// Member types have no picker in the builder, and the contract's definition
|
|
138
|
+
// has no field for them, so a bundle never carries one.
|
|
139
|
+
const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
|
|
140
|
+
const languages = set.languages.filter((language) =>
|
|
141
|
+
from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
contentTypes.length === 0 &&
|
|
146
|
+
mediaTypes.length === 0 &&
|
|
147
|
+
dataTypes.length === 0 &&
|
|
148
|
+
languages.length === 0
|
|
149
|
+
)
|
|
150
|
+
return entries
|
|
151
|
+
|
|
152
|
+
put('schema.toml', writeSchemaVersion(set.version))
|
|
153
|
+
for (const type of contentTypes)
|
|
154
|
+
put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
|
|
155
|
+
for (const type of mediaTypes)
|
|
156
|
+
put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
|
|
157
|
+
for (const type of dataTypes)
|
|
158
|
+
put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
|
|
159
|
+
if (languages.length > 0) put('languages.toml', writeLanguages(languages))
|
|
160
|
+
return entries
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Several exports as one bundle: the nodes deduped by key, the counts summed,
|
|
165
|
+
* and the dependencies and selector unioned, so the manifest describes what the
|
|
166
|
+
* bundle actually holds rather than what one of the exports held. `writeBundle`
|
|
167
|
+
* recomputes the integrity hash over the files it writes.
|
|
168
|
+
*/
|
|
169
|
+
function mergeContentSets(sets: readonly ContentSet[]): ContentSet | undefined {
|
|
170
|
+
const [first, ...rest] = sets
|
|
171
|
+
if (!first) return undefined
|
|
172
|
+
if (rest.length === 0) return first
|
|
173
|
+
|
|
174
|
+
const nodes = new Map<string, BundleNode>()
|
|
175
|
+
for (const set of sets) for (const node of set.nodes) nodes.set(node.key, node)
|
|
176
|
+
|
|
177
|
+
const counts: Partial<Record<BundleKind, number>> = {}
|
|
178
|
+
for (const node of nodes.values()) counts[node.kind] = (counts[node.kind] ?? 0) + 1
|
|
179
|
+
|
|
180
|
+
const expected = new Map<string, BundleDependency>()
|
|
181
|
+
const blobs = new Map<string, BundleBlob>()
|
|
182
|
+
const carried = new Set<string>()
|
|
183
|
+
const contentTypes = new Map<string, { key: string; alias: string }>()
|
|
184
|
+
const components = new Set<string>()
|
|
185
|
+
const languages = new Set<string>()
|
|
186
|
+
const roots: string[] = []
|
|
187
|
+
const asGiven: string[] = []
|
|
188
|
+
for (const set of sets) {
|
|
189
|
+
for (const dependency of set.manifest.dependencies.expected)
|
|
190
|
+
expected.set(dependency.key, dependency)
|
|
191
|
+
for (const key of set.manifest.dependencies.carried) carried.add(key)
|
|
192
|
+
for (const type of set.manifest.dependencies.schema.contentTypes)
|
|
193
|
+
contentTypes.set(type.key, type)
|
|
194
|
+
for (const alias of set.manifest.dependencies.schema.components) components.add(alias)
|
|
195
|
+
for (const iso of set.manifest.dependencies.schema.languages) languages.add(iso)
|
|
196
|
+
for (const blob of set.manifest.blobs) blobs.set(blob.key, blob)
|
|
197
|
+
roots.push(...set.manifest.selector.roots)
|
|
198
|
+
asGiven.push(...set.manifest.selector.asGiven)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
nodes: [...nodes.values()],
|
|
203
|
+
manifest: {
|
|
204
|
+
...first.manifest,
|
|
205
|
+
counts,
|
|
206
|
+
// True of the merged set: a group that asked for descendants is already
|
|
207
|
+
// expanded into its nodes, so the flag has done its work.
|
|
208
|
+
selector: { roots, asGiven, descendants: sets.some((s) => s.manifest.selector.descendants) },
|
|
209
|
+
dependencies: {
|
|
210
|
+
carried: [...carried],
|
|
211
|
+
expected: [...expected.values()],
|
|
212
|
+
schema: {
|
|
213
|
+
contentTypes: [...contentTypes.values()],
|
|
214
|
+
components: [...components],
|
|
215
|
+
languages: [...languages],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
blobs: [...blobs.values()],
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createPackagePort(db: Db, options: PackagePortOptions): PackagePort {
|
|
224
|
+
const bundles = new BundleRepository(db)
|
|
225
|
+
|
|
226
|
+
const validate = (input: PackageDefinitionInput): PackageWriteResult | undefined =>
|
|
227
|
+
input.name.length === 0 ? { ok: false, status: 'InvalidName' } : undefined
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The files a selection of file-system paths resolves to, under one section.
|
|
231
|
+
*
|
|
232
|
+
* The section is a logical name — `styles`, not `css` — because a site holds
|
|
233
|
+
* these wherever it is configured to, and the importer is what maps a section
|
|
234
|
+
* onto a directory.
|
|
235
|
+
*/
|
|
236
|
+
const fileEntries = async (
|
|
237
|
+
port: FileSystemPort | undefined,
|
|
238
|
+
paths: readonly string[],
|
|
239
|
+
section: BundleSection,
|
|
240
|
+
): Promise<BundleFile[]> => {
|
|
241
|
+
if (!port) return []
|
|
242
|
+
const files: BundleFile[] = []
|
|
243
|
+
for (const path of paths) {
|
|
244
|
+
const file = await port.read(path)
|
|
245
|
+
if (!file) continue
|
|
246
|
+
const relative = file.path.replace(/^\/+/, '')
|
|
247
|
+
files.push({ path: `${BUNDLE_SECTIONS[section]}/${relative}`, text: file.content })
|
|
248
|
+
}
|
|
249
|
+
return files
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const componentEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
|
|
253
|
+
if (keys.length === 0) return []
|
|
254
|
+
const templates = new ComponentRepository(db, options.componentFiles)
|
|
255
|
+
const all = await templates.all()
|
|
256
|
+
const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
|
|
257
|
+
const files: BundleFile[] = []
|
|
258
|
+
for (const template of all) {
|
|
259
|
+
if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
|
|
260
|
+
continue
|
|
261
|
+
files.push({
|
|
262
|
+
path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
|
|
263
|
+
text: template.content ?? '',
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
return files
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The components a bundle carries, from one list that holds two kinds of
|
|
271
|
+
* name.
|
|
272
|
+
*
|
|
273
|
+
* The contract has `templates` and `partialViews`; this side has one list, so
|
|
274
|
+
* a picked component arrives either as a template alias — a row in
|
|
275
|
+
* `template`, whose file is read through the alias — or as a path in the
|
|
276
|
+
* components tree. Both are tried and the results are keyed by path, so a
|
|
277
|
+
* component picked both ways is carried once.
|
|
278
|
+
*/
|
|
279
|
+
const componentSection = async (picked: readonly string[]): Promise<BundleFile[]> => {
|
|
280
|
+
const byPath = new Map<string, BundleFile>()
|
|
281
|
+
for (const file of await componentEntries(picked)) byPath.set(file.path, file)
|
|
282
|
+
for (const file of await fileEntries(options.components, picked, 'components'))
|
|
283
|
+
byPath.set(file.path, file)
|
|
284
|
+
return [...byPath.values()]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const dictionaryEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
|
|
288
|
+
if (keys.length === 0) return []
|
|
289
|
+
const repo = new DictionaryRepository(db)
|
|
290
|
+
const items = []
|
|
291
|
+
for (const key of keys) items.push(...(await dictionaryToUdt(repo, key)))
|
|
292
|
+
if (items.length === 0) return []
|
|
293
|
+
return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The content, media and element picks as one transfer bundle, blobs included.
|
|
298
|
+
*
|
|
299
|
+
* Three groups, not one: the contract carries `contentLoadChildNodes` and
|
|
300
|
+
* `mediaLoadChildNodes` as separate booleans, and elements have no such flag
|
|
301
|
+
* at all. `descendants` is per export, so a definition whose two flags
|
|
302
|
+
* disagree needs an export each — otherwise picking a media folder's children
|
|
303
|
+
* would quietly drag a content page's children in too. The sets are merged
|
|
304
|
+
* back into one bundle, which is what the destination reads.
|
|
305
|
+
*/
|
|
306
|
+
const contentEntries = async (
|
|
307
|
+
from: PackageDefinition,
|
|
308
|
+
): Promise<{ set: ContentSet | undefined; blobs: ReadonlyMap<string, Uint8Array> }> => {
|
|
309
|
+
const groups: Array<{ refs: string[]; descendants: boolean }> = [
|
|
310
|
+
{
|
|
311
|
+
refs: from.contentNodeId ? [from.contentNodeId] : [],
|
|
312
|
+
descendants: from.contentLoadChildNodes,
|
|
313
|
+
},
|
|
314
|
+
{ refs: from.mediaIds, descendants: from.mediaLoadChildNodes },
|
|
315
|
+
{ refs: from.elementIds ?? [], descendants: false },
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
const sets: ContentSet[] = []
|
|
319
|
+
const blobKeys = new Set<string>()
|
|
320
|
+
for (const group of groups) {
|
|
321
|
+
if (group.refs.length === 0) continue
|
|
322
|
+
const roots: NodeRow[] = []
|
|
323
|
+
for (const ref of group.refs) {
|
|
324
|
+
const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
|
|
325
|
+
// A pick that has since been deleted is dropped rather than failing the
|
|
326
|
+
// download: the definition outlives the content it names.
|
|
327
|
+
if (resolved.ok) roots.push(resolved.node)
|
|
328
|
+
}
|
|
329
|
+
if (roots.length === 0) continue
|
|
330
|
+
const exported = await exportBundle(db, {
|
|
331
|
+
roots,
|
|
332
|
+
asGiven: group.refs,
|
|
333
|
+
descendants: group.descendants,
|
|
334
|
+
snapshot: 'published',
|
|
335
|
+
blueprints: false,
|
|
336
|
+
withBlobs: true,
|
|
337
|
+
siteName: options.siteName,
|
|
338
|
+
nodeId: options.nodeId,
|
|
339
|
+
})
|
|
340
|
+
sets.push(exported.set)
|
|
341
|
+
for (const key of exported.blobKeys) blobKeys.add(key)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const merged = mergeContentSets(sets)
|
|
345
|
+
if (!merged) return { set: undefined, blobs: new Map() }
|
|
346
|
+
|
|
347
|
+
const { bytes } =
|
|
348
|
+
blobKeys.size > 0
|
|
349
|
+
? await readBlobs(options.mediaStore, [...blobKeys])
|
|
350
|
+
: { bytes: new Map<string, Uint8Array>() }
|
|
351
|
+
return { set: merged, blobs: bytes }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
marketplaceUrl: () => options.marketplaceUrl,
|
|
356
|
+
|
|
357
|
+
async list(paging): Promise<Page<PackageDefinition>> {
|
|
358
|
+
const result = await bundles.list(paging)
|
|
359
|
+
return { total: result.total, items: result.items.map(definition) }
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async byId(id) {
|
|
363
|
+
const row = await bundles.byId(id)
|
|
364
|
+
return row ? definition(row) : undefined
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
async create(input, id) {
|
|
368
|
+
const invalid = validate(input)
|
|
369
|
+
if (invalid) return invalid
|
|
370
|
+
if (await bundles.byName(input.name)) return { ok: false, status: 'DuplicateName' }
|
|
371
|
+
const row = await bundles.create(stored(input), id)
|
|
372
|
+
return { ok: true, id: row.id }
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
async update(id, input) {
|
|
376
|
+
const invalid = validate(input)
|
|
377
|
+
if (invalid) return invalid
|
|
378
|
+
if (!(await bundles.byId(id))) return { ok: false, status: 'NotFound' }
|
|
379
|
+
const clash = await bundles.byName(input.name)
|
|
380
|
+
if (clash && clash.id !== id) return { ok: false, status: 'DuplicateName' }
|
|
381
|
+
await bundles.update(id, stored(input))
|
|
382
|
+
return { ok: true, id }
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async remove(id) {
|
|
386
|
+
if (!(await bundles.byId(id))) return 'notFound'
|
|
387
|
+
await bundles.delete(id)
|
|
388
|
+
return 'deleted'
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The definition as one bundle, packed as the npm package it is.
|
|
393
|
+
*
|
|
394
|
+
* Everything goes through `writeBundle` so there is a single manifest over
|
|
395
|
+
* the whole payload: it declares the sections, carries the hash that covers
|
|
396
|
+
* them, and names the bundle. The package around it is what gives it a
|
|
397
|
+
* name, a version and the core it was written for, so the download is
|
|
398
|
+
* something `bun add ./file.tgz` and `bun publish` take as it is.
|
|
399
|
+
*/
|
|
400
|
+
async build(id): Promise<BuiltPackage | undefined> {
|
|
401
|
+
const row = await bundles.byId(id)
|
|
402
|
+
if (!row) return undefined
|
|
403
|
+
const from = definition(row)
|
|
404
|
+
const pkg = bundlePackageOf(row)
|
|
405
|
+
|
|
406
|
+
// The schema version the site is actually at, so the files a package
|
|
407
|
+
// carries say where they came from rather than claiming version 0.
|
|
408
|
+
const state = await currentSchemaState(db).catch(() => undefined)
|
|
409
|
+
const schema = await exportSchemaSet(db, state?.version ?? '0')
|
|
410
|
+
|
|
411
|
+
const sections: BundleFile[] = [
|
|
412
|
+
...schemaEntries(schema, from),
|
|
413
|
+
...(await componentSection([...from.templates, ...from.partialViews])),
|
|
414
|
+
...(await fileEntries(options.stylesheets, from.stylesheets, 'styles')),
|
|
415
|
+
...(await fileEntries(options.scripts, from.scripts, 'scripts')),
|
|
416
|
+
...(await dictionaryEntries(from.dictionaryItems)),
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
const content = await contentEntries(from)
|
|
420
|
+
// A package with nothing picked is still a bundle: an empty selection
|
|
421
|
+
// produces an empty set rather than throwing, which is what gives a
|
|
422
|
+
// schema-only package a manifest of its own.
|
|
423
|
+
const set =
|
|
424
|
+
content.set ??
|
|
425
|
+
(
|
|
426
|
+
await exportBundle(db, {
|
|
427
|
+
roots: [],
|
|
428
|
+
asGiven: [],
|
|
429
|
+
descendants: false,
|
|
430
|
+
snapshot: 'published',
|
|
431
|
+
blueprints: false,
|
|
432
|
+
withBlobs: false,
|
|
433
|
+
siteName: options.siteName,
|
|
434
|
+
nodeId: options.nodeId,
|
|
435
|
+
})
|
|
436
|
+
).set
|
|
437
|
+
|
|
438
|
+
const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
|
|
439
|
+
const encoder = new TextEncoder()
|
|
440
|
+
const files = writeBundlePackage(writeBundle(labelled, content.blobs, sections), {
|
|
441
|
+
name: pkg.name,
|
|
442
|
+
version: pkg.version,
|
|
443
|
+
id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
|
|
444
|
+
label: from.name,
|
|
445
|
+
peerRange: `^${VERSION}`,
|
|
446
|
+
})
|
|
447
|
+
const entries = files.map((file) => ({
|
|
448
|
+
path: file.path,
|
|
449
|
+
bytes: file.bytes ?? encoder.encode(file.text ?? ''),
|
|
450
|
+
}))
|
|
451
|
+
return {
|
|
452
|
+
fileName: packageFileName(pkg.name, pkg.version),
|
|
453
|
+
contentType: 'application/gzip',
|
|
454
|
+
bytes: writeNpmTarball(entries),
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A created bundle's npm package as the Created view edits it. */
|
|
461
|
+
export interface CreatedBundlePackage {
|
|
462
|
+
name: string
|
|
463
|
+
version: string
|
|
464
|
+
/** Whether each was set, rather than derived from the definition's name. */
|
|
465
|
+
set: { name: boolean; version: boolean }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
|
|
469
|
+
|
|
470
|
+
/** The npm package a definition builds as, or undefined when it is gone. */
|
|
471
|
+
export async function readCreatedBundlePackage(
|
|
472
|
+
db: Db,
|
|
473
|
+
id: string,
|
|
474
|
+
): Promise<CreatedBundlePackage | undefined> {
|
|
475
|
+
const row = await new BundleRepository(db).byId(id)
|
|
476
|
+
if (!row) return undefined
|
|
477
|
+
return {
|
|
478
|
+
...bundlePackageOf(row),
|
|
479
|
+
set: { name: row.packageName !== null, version: row.packageVersion !== null },
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Sets the npm package a definition builds as. An empty value goes back to its
|
|
485
|
+
* default; anything else must be what npm itself would accept.
|
|
486
|
+
*/
|
|
487
|
+
export async function writeCreatedBundlePackage(
|
|
488
|
+
db: Db,
|
|
489
|
+
id: string,
|
|
490
|
+
input: { name?: unknown; version?: unknown },
|
|
491
|
+
): Promise<{ ok: true; package: CreatedBundlePackage } | { ok: false; message: string }> {
|
|
492
|
+
const bundles = new BundleRepository(db)
|
|
493
|
+
if (!(await bundles.byId(id))) return { ok: false, message: 'That bundle no longer exists.' }
|
|
494
|
+
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
|
|
495
|
+
const name = text(input.name)
|
|
496
|
+
const version = text(input.version)
|
|
497
|
+
if (name && !isPackageName(name))
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
message:
|
|
501
|
+
'A package name is lowercase letters, digits, dots, hyphens and underscores, optionally with an @scope/ in front.',
|
|
502
|
+
}
|
|
503
|
+
if (version && !SEMVER.test(version))
|
|
504
|
+
return { ok: false, message: 'A version is three numbers, like 1.0.0.' }
|
|
505
|
+
await bundles.setPackage(id, { name: name || null, version: version || null })
|
|
506
|
+
return { ok: true, package: (await readCreatedBundlePackage(db, id)) as CreatedBundlePackage }
|
|
507
|
+
}
|