@crombolo/server 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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,783 @@
1
+ /**
2
+ * Adapters binding the data repositories to the Management API ports.
3
+ *
4
+ * Tree shaping lives here because "is this a folder", "does it have children" and
5
+ * "which icon" are presentation concerns rather than storage ones.
6
+ */
7
+ import type {
8
+ ContentTypePort,
9
+ DataTypePort,
10
+ FolderPort,
11
+ PortFailure,
12
+ Principal,
13
+ SkipTake,
14
+ TemplatePort,
15
+ TreePage,
16
+ } from '@crombolo/api-management'
17
+ import {
18
+ type ComponentModel,
19
+ type ContentTypeAggregate,
20
+ type DataTypeModel,
21
+ ObjectTypes,
22
+ type Page,
23
+ SystemNodes,
24
+ type TreeItem,
25
+ } from '@crombolo/core'
26
+ import {
27
+ CONTENT_TYPE_OBJECT_TYPES,
28
+ type ComponentFileStore,
29
+ ComponentRepository,
30
+ type ContentTypeKind,
31
+ ContentTypeRepository,
32
+ type ContentTypeRepositoryOptions,
33
+ DataTypeRepository,
34
+ type Db,
35
+ FolderRepository,
36
+ Locks,
37
+ type NodeRepository,
38
+ type NodeRow,
39
+ } from '@crombolo/data'
40
+ import { aliasFromName } from '@crombolo/schema'
41
+ import { readContentTypeUdt, writeContentTypeUdt } from '../content-type-udt.ts'
42
+ import { classifyTypeChange } from '../schema-classify.ts'
43
+ import { contentTypeJsonSchema, schemaForStorage, valueTypeName } from '../value-schemas.ts'
44
+ import { createSchemaFileWriter, type SchemaFiles } from './schema-files.ts'
45
+
46
+ async function toTreeItems(
47
+ nodes: NodeRepository,
48
+ rows: readonly NodeRow[],
49
+ objectType: string,
50
+ icon: (row: NodeRow) => string | null,
51
+ ): Promise<TreeItem[]> {
52
+ // One grouped query rather than one per row.
53
+ const counts = await nodes.childCounts(
54
+ rows.map((row) => row.id),
55
+ objectType,
56
+ )
57
+ const items: TreeItem[] = []
58
+ for (const row of rows) {
59
+ const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
60
+ items.push({
61
+ key: row.key,
62
+ name: row.text ?? '',
63
+ hasChildren: (counts.get(row.id) ?? 0) > 0,
64
+ parentKey: parent?.key ?? null,
65
+ icon: icon(row),
66
+ isFolder: false,
67
+ })
68
+ }
69
+ return items
70
+ }
71
+
72
+ /**
73
+ * A settings tree mixes folders and items. Folders come first; an item's
74
+ * decoration (icon, element flag, editor) is filled in by the caller.
75
+ */
76
+ export function createSettingsTree(
77
+ nodes: NodeRepository,
78
+ containerType: string,
79
+ itemType: string,
80
+ decorate: (item: TreeItem) => Promise<void>,
81
+ ) {
82
+ const types = (foldersOnly: boolean | undefined) =>
83
+ foldersOnly ? [containerType] : [containerType, itemType]
84
+
85
+ const shape = async (rows: readonly NodeRow[]): Promise<TreeItem[]> => {
86
+ const items: TreeItem[] = []
87
+ for (const row of rows) {
88
+ const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
89
+ const inside = await nodes.childrenOf(row.id, [containerType, itemType], 0, 1)
90
+ const item: TreeItem = {
91
+ key: row.key,
92
+ name: row.text ?? '',
93
+ hasChildren: inside.total > 0,
94
+ parentKey: parent && parent.objectType === containerType ? parent.key : null,
95
+ icon: row.objectType === containerType ? 'icon-folder' : null,
96
+ isFolder: row.objectType === containerType,
97
+ }
98
+ if (!item.isFolder) await decorate(item)
99
+ items.push(item)
100
+ }
101
+ return items
102
+ }
103
+
104
+ const page = async (parentKey: string | null, paging: TreePage): Promise<Page<TreeItem>> => {
105
+ const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
106
+ if (parentId === undefined) return { total: 0, items: [] }
107
+ const result = await nodes.childrenOf(
108
+ parentId,
109
+ types(paging.foldersOnly),
110
+ paging.skip,
111
+ paging.take,
112
+ )
113
+ return { total: result.total, items: await shape(result.items) }
114
+ }
115
+
116
+ return {
117
+ shape,
118
+ nodes,
119
+ containerType,
120
+ itemType,
121
+ treeRoot: (paging: TreePage) => page(null, paging),
122
+ treeChildren: (parentKey: string, paging: TreePage) => page(parentKey, paging),
123
+ ancestors: async (descendantKey: string) => shape(await nodes.ancestors(descendantKey)),
124
+ items: async (keys: readonly string[]) => shape(await nodes.byKeys(keys)),
125
+ async siblings(target: string, before: number, after: number, foldersOnly?: boolean) {
126
+ const window = await nodes.siblings(target, types(foldersOnly), before, after)
127
+ if (!window) return undefined
128
+ return { ...window, items: await shape(window.items) }
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Runs a port's schema-changing methods under the schema lock, so a change and
135
+ * the files it rewrites land together before the schema watcher reads them.
136
+ */
137
+ export function underSchemaLock<P extends object>(
138
+ db: Db,
139
+ port: P,
140
+ methods: readonly (keyof P)[],
141
+ ): P {
142
+ const locked = { ...port }
143
+ for (const name of methods) {
144
+ const method = port[name] as (...args: unknown[]) => Promise<unknown>
145
+ locked[name] = ((...args: unknown[]) =>
146
+ db.locks.withLock(Locks.Schema, () => method(...args))) as P[typeof name]
147
+ }
148
+ return locked
149
+ }
150
+
151
+ export function createFolderPort(
152
+ folders: FolderRepository,
153
+ tree: ReturnType<typeof createSettingsTree>,
154
+ afterChange: () => Promise<void>,
155
+ ): FolderPort {
156
+ return {
157
+ folder: (key) => folders.byKey(key),
158
+ async createFolder(input) {
159
+ const created = await folders.create(input)
160
+ return { key: created.key }
161
+ },
162
+ async renameFolder(key, name) {
163
+ const ok = await folders.rename(key, name)
164
+ if (ok) await afterChange()
165
+ return ok
166
+ },
167
+ async deleteFolder(key) {
168
+ const result = await folders.delete(key)
169
+ if (result === 'deleted') await afterChange()
170
+ return result
171
+ },
172
+ siblings: tree.siblings,
173
+ async search(query, kind, paging) {
174
+ const types = [
175
+ ...(kind !== 'Item' ? [tree.containerType] : []),
176
+ ...(kind !== 'Folder' ? [tree.itemType] : []),
177
+ ]
178
+ const hits: NodeRow[] = []
179
+ for (const type of types)
180
+ hits.push(...(await tree.nodes.search(type, query, { skip: 0, take: 10_000 })).items)
181
+ hits.sort(
182
+ (a, b) =>
183
+ Number(b.objectType === tree.containerType) -
184
+ Number(a.objectType === tree.containerType) ||
185
+ (a.text ?? '').localeCompare(b.text ?? ''),
186
+ )
187
+ return {
188
+ total: hits.length,
189
+ items: await tree.shape(hits.slice(paging.skip, paging.skip + paging.take)),
190
+ }
191
+ },
192
+ }
193
+ }
194
+
195
+ export interface ContentTypePortOptions extends ContentTypeRepositoryOptions {
196
+ /** Content types by default; media types share the port. */
197
+ kind?: ContentTypeKind
198
+ /** Lets "Create template" write the view file. */
199
+ componentFiles?: ComponentFileStore
200
+ }
201
+
202
+ export function createContentTypePort(
203
+ db: Db,
204
+ schemaFiles?: SchemaFiles,
205
+ options: ContentTypePortOptions = {},
206
+ ): ContentTypePort {
207
+ const kind = options.kind ?? 'document'
208
+ const repo = new ContentTypeRepository(db, { nodeState: options.nodeState, kind })
209
+ const nodes = repo.nodes
210
+ const objectType = repo.objectType
211
+ const containerType = CONTENT_TYPE_OBJECT_TYPES[kind].container
212
+ const folders = new FolderRepository(db, containerType, objectType)
213
+ const files = schemaFiles ? createSchemaFileWriter(db, schemaFiles, kind) : undefined
214
+ const templates = new ComponentRepository(db, options.componentFiles)
215
+ const rewrite = () => files?.rewriteAll() ?? Promise.resolve()
216
+
217
+ const decorate = async (item: TreeItem) => {
218
+ const aggregate = await repo.byKey(item.key)
219
+ item.icon = aggregate?.icon ?? (kind === 'document' ? 'icon-document' : 'icon-picture')
220
+ item.isElement = aggregate?.isElement ?? false
221
+ }
222
+ const tree = createSettingsTree(nodes, containerType, objectType, decorate)
223
+ const shape = async (rows: readonly NodeRow[]): Promise<TreeItem[]> => {
224
+ const items = await toTreeItems(nodes, rows, objectType, () => null)
225
+ for (const item of items) await decorate(item)
226
+ return items
227
+ }
228
+
229
+ return underSchemaLock(
230
+ db,
231
+ {
232
+ byKey: (key) => repo.byKey(key),
233
+ byKeys: (keys) => repo.byKeys(keys),
234
+
235
+ async create(aggregate, principal: Principal) {
236
+ if (await repo.aliasExists(aggregate.alias)) {
237
+ return { ok: false as const, reason: `The alias '${aggregate.alias}' is already in use.` }
238
+ }
239
+ const refused = files?.refuse(aggregate)
240
+ if (refused) return refused
241
+ // A new type can only be additive, and the version moves before the write so
242
+ // the file that lands carries the version it belongs to.
243
+ await files?.bumpVersion('additive')
244
+ await repo.save(aggregate)
245
+ await files?.write(aggregate)
246
+ void principal
247
+ return { ok: true as const }
248
+ },
249
+
250
+ async update(key, aggregate, principal: Principal) {
251
+ if (await repo.aliasExists(aggregate.alias, key)) {
252
+ return { ok: false as const, reason: `The alias '${aggregate.alias}' is already in use.` }
253
+ }
254
+ const refused = files?.refuse(aggregate)
255
+ if (refused) return refused
256
+ const before = await repo.byKey(key)
257
+ // Classified here, while the database still holds the old type: once it is
258
+ // saved there is nothing left to compare it with.
259
+ if (files)
260
+ await files.bumpVersion(await classifyTypeChange(db, before, { ...aggregate, key }))
261
+ // The editor does not send tree placement; keep what the type has.
262
+ await repo.save({ ...aggregate, key, parentKey: before?.parentKey ?? aggregate.parentKey })
263
+ await files?.write({ ...aggregate, key }, before?.alias)
264
+ void principal
265
+ return { ok: true as const }
266
+ },
267
+
268
+ async remove(key) {
269
+ const before = await repo.byKey(key)
270
+ const removed = await repo.delete(key)
271
+ if (removed && before) await files?.remove(before.alias)
272
+ return removed
273
+ },
274
+
275
+ allowedAtRoot: (paging) => repo.allowedAtRoot(paging.skip, paging.take),
276
+ allowedInLibrary: (paging) => repo.allowedInLibrary(paging.skip, paging.take),
277
+ allowedChildren: (key, paging) => repo.allowedChildren(key, paging.skip, paging.take),
278
+ search: (query, options) => repo.search(query, options),
279
+ compositionReferences: (key) => repo.compositionReferences(key),
280
+ allowedParents: (key) => repo.allowedParents(key),
281
+
282
+ async availableCompositions(request) {
283
+ const available = await repo.availableCompositions(request)
284
+ const out: Array<{
285
+ type: ContentTypeAggregate
286
+ folderPath: string[]
287
+ isCompatible: boolean
288
+ }> = []
289
+ for (const a of available) out.push({ ...a, folderPath: await folders.pathOf(a.type.key) })
290
+ return out
291
+ },
292
+
293
+ async move(key, parentKey) {
294
+ const moved = await repo.move(key, parentKey)
295
+ if (moved) await rewrite()
296
+ return moved
297
+ },
298
+
299
+ async copy(key, parentKey) {
300
+ const copy = await repo.copy(key, parentKey)
301
+ if (copy) await files?.write(copy)
302
+ return copy
303
+ },
304
+
305
+ async createTemplate(key, template, principal: Principal) {
306
+ const aggregate = await repo.byKey(key)
307
+ if (!aggregate) {
308
+ const failure: PortFailure = {
309
+ ok: false,
310
+ reason: 'The content type could not be found.',
311
+ status: 404,
312
+ }
313
+ return failure
314
+ }
315
+ const refused = files?.refuse(aggregate)
316
+ if (refused) return refused
317
+ const alias = template.alias || aggregate.alias
318
+ const name = template.name || aggregate.name
319
+ const saved =
320
+ (await templates.byAlias(alias)) ??
321
+ (await templates.save({
322
+ key: crypto.randomUUID(),
323
+ name,
324
+ alias,
325
+ content: scaffoldTemplate(name, alias),
326
+ }))
327
+ const allowed = aggregate.allowedComponentKeys.includes(saved.key)
328
+ ? aggregate.allowedComponentKeys
329
+ : [...aggregate.allowedComponentKeys, saved.key]
330
+ const updated: ContentTypeAggregate = {
331
+ ...aggregate,
332
+ allowedComponentKeys: allowed,
333
+ defaultComponentKey:
334
+ template.isDefault || !aggregate.defaultComponentKey
335
+ ? saved.key
336
+ : aggregate.defaultComponentKey,
337
+ }
338
+ await repo.save(updated)
339
+ await files?.write(updated)
340
+ void principal
341
+ return { ok: true as const, key: saved.key }
342
+ },
343
+
344
+ async exportUdt(key) {
345
+ const aggregate = await repo.byKey(key)
346
+ if (!aggregate) return undefined
347
+ const aliases = new Map<string, string>()
348
+ for (const reference of [
349
+ ...aggregate.compositions.map((c) => c.contentTypeKey),
350
+ ...aggregate.allowedContentTypes.map((a) => a.contentTypeKey),
351
+ ]) {
352
+ const other = await repo.byKey(reference)
353
+ if (other) aliases.set(reference, other.alias)
354
+ }
355
+ const componentAliases = new Map<string, string>()
356
+ for (const componentKey of [
357
+ ...aggregate.allowedComponentKeys,
358
+ ...(aggregate.defaultComponentKey ? [aggregate.defaultComponentKey] : []),
359
+ ]) {
360
+ const template = await templates.byKey(componentKey)
361
+ if (template) componentAliases.set(componentKey, template.alias)
362
+ }
363
+ return writeContentTypeUdt(aggregate, kind, {
364
+ aliasOf: (k: string) => aliases.get(k),
365
+ templateAliasOf: (k: string) => componentAliases.get(k),
366
+ })
367
+ },
368
+
369
+ async importUdt(xml, targetKey) {
370
+ const parsed = readContentTypeUdt(xml)
371
+ if (!parsed) return { status: 'invalid', reason: 'The file is not a content type export.' }
372
+ if (parsed.kind !== kind)
373
+ return { status: 'mismatch', reason: `The file describes a ${parsed.kind} type.` }
374
+
375
+ const existing = targetKey ? await repo.byKey(targetKey) : undefined
376
+ if (targetKey && !existing) return { status: 'not-found' }
377
+ if (existing && parsed.alias !== existing.alias)
378
+ return {
379
+ status: 'mismatch',
380
+ reason: `The file describes '${parsed.alias}', not '${existing.alias}'.`,
381
+ }
382
+
383
+ const dataTypes = new DataTypeRepository(db)
384
+ // Creating from a file exported elsewhere: its container and property keys
385
+ // still belong to that type, so a new type mints its own. An update keeps
386
+ // them, because the alias guard above proved the file describes this type.
387
+ const reuseKeys = existing !== undefined
388
+ const containers = parsed.containers.map(
389
+ (container: (typeof parsed.containers)[number]) => ({
390
+ key: (reuseKeys && container.key) || crypto.randomUUID(),
391
+ name: container.name,
392
+ alias: container.alias,
393
+ type: container.type,
394
+ sortOrder: container.sortOrder,
395
+ parentKey: null,
396
+ }),
397
+ )
398
+ const byAlias = new Map(
399
+ containers.map((container: { alias: string; key: string }) => [
400
+ container.alias,
401
+ container.key,
402
+ ]),
403
+ )
404
+
405
+ const properties = []
406
+ for (const property of parsed.properties) {
407
+ if (!property.dataTypeKey)
408
+ return { status: 'invalid', reason: `'${property.alias}' names no data type.` }
409
+ if (!(await dataTypes.byKey(property.dataTypeKey)))
410
+ return {
411
+ status: 'invalid',
412
+ reason: `The data type for '${property.alias}' is not on this site.`,
413
+ }
414
+ properties.push({
415
+ key: (reuseKeys && property.key) || crypto.randomUUID(),
416
+ alias: property.alias,
417
+ name: property.name,
418
+ description: property.description,
419
+ dataTypeKey: property.dataTypeKey,
420
+ containerKey: property.tabAlias ? (byAlias.get(property.tabAlias) ?? null) : null,
421
+ sortOrder: property.sortOrder,
422
+ variesByCulture: property.variesByCulture,
423
+ variesBySegment: property.variesBySegment,
424
+ mandatory: property.mandatory,
425
+ mandatoryMessage: property.mandatoryMessage,
426
+ regEx: property.regEx,
427
+ regExMessage: property.regExMessage,
428
+ labelOnTop: property.labelOnTop,
429
+ })
430
+ }
431
+
432
+ const keyFor = async (alias: string) => (await repo.byAlias(alias))?.key
433
+ const compositions = []
434
+ for (const alias of parsed.compositionAliases) {
435
+ const found = await keyFor(alias)
436
+ if (found)
437
+ compositions.push({ contentTypeKey: found, compositionType: 'Composition' as const })
438
+ }
439
+ const allowed = []
440
+ for (const [index, alias] of parsed.allowedAliases.entries()) {
441
+ const found = await keyFor(alias)
442
+ if (found) allowed.push({ contentTypeKey: found, sortOrder: index })
443
+ }
444
+ const componentKeys = []
445
+ for (const alias of parsed.componentAliases) {
446
+ const template = await templates.byAlias(alias)
447
+ if (template) componentKeys.push(template.key)
448
+ }
449
+ const defaultComponent = parsed.defaultTemplateAlias
450
+ ? ((await templates.byAlias(parsed.defaultTemplateAlias))?.key ?? null)
451
+ : null
452
+
453
+ const aggregate: ContentTypeAggregate = {
454
+ ...(existing ?? {
455
+ collectionKey: null,
456
+ cleanup: {
457
+ preventCleanup: false,
458
+ keepAllVersionsNewerThanDays: null,
459
+ keepLatestVersionPerDayForDays: null,
460
+ },
461
+ parentKey: null,
462
+ }),
463
+ key: existing?.key ?? crypto.randomUUID(),
464
+ alias: parsed.alias,
465
+ name: parsed.name,
466
+ description: parsed.description,
467
+ icon: parsed.icon ?? 'icon-document',
468
+ allowedAsRoot: parsed.allowedAsRoot,
469
+ isElement: parsed.isElement,
470
+ allowedInLibrary: parsed.allowedInLibrary,
471
+ variesByCulture: parsed.variesByCulture,
472
+ variesBySegment: parsed.variesBySegment,
473
+ properties,
474
+ containers,
475
+ compositions,
476
+ allowedContentTypes: allowed,
477
+ allowedComponentKeys: componentKeys,
478
+ defaultComponentKey: defaultComponent,
479
+ } as ContentTypeAggregate
480
+
481
+ if (!existing && (await repo.aliasExists(aggregate.alias)))
482
+ return { status: 'invalid', reason: `The alias '${aggregate.alias}' is already in use.` }
483
+ await repo.save(aggregate)
484
+ await files?.write(aggregate)
485
+ return { status: 'ok', key: aggregate.key }
486
+ },
487
+
488
+ async jsonSchema(key) {
489
+ const aggregate = await repo.byKey(key)
490
+ if (!aggregate) return undefined
491
+ const dataTypes = new DataTypeRepository(db)
492
+ const properties = []
493
+ for (const property of aggregate.properties) {
494
+ const dataType = await dataTypes.byKey(property.dataTypeKey)
495
+ properties.push({
496
+ alias: property.alias,
497
+ name: property.name,
498
+ description: property.description,
499
+ mandatory: property.mandatory,
500
+ storage: dataType?.dbType ?? 'Ntext',
501
+ })
502
+ }
503
+ return contentTypeJsonSchema(aggregate, properties)
504
+ },
505
+
506
+ folders: underSchemaLock(db, createFolderPort(folders, tree, rewrite), [
507
+ 'createFolder',
508
+ 'renameFolder',
509
+ 'deleteFolder',
510
+ ]),
511
+ treeRoot: tree.treeRoot,
512
+ treeChildren: tree.treeChildren,
513
+
514
+ async root(paging: SkipTake): Promise<Page<TreeItem>> {
515
+ const page = await nodes.roots(objectType, paging.skip, paging.take)
516
+ return { total: page.total, items: await shape(page.items) }
517
+ },
518
+
519
+ async children(parentKey: string, paging: SkipTake): Promise<Page<TreeItem>> {
520
+ const parent = await nodes.byKey(parentKey)
521
+ if (!parent) return { total: 0, items: [] }
522
+ const page = await nodes.children(parent.id, objectType, paging.skip, paging.take)
523
+ return { total: page.total, items: await shape(page.items) }
524
+ },
525
+
526
+ ancestors: tree.ancestors,
527
+ items: tree.items,
528
+ },
529
+ ['create', 'update', 'remove', 'move', 'copy', 'createTemplate', 'importUdt'],
530
+ )
531
+ }
532
+
533
+ export function createDataTypePort(db: Db, schemaFiles?: SchemaFiles): DataTypePort {
534
+ const repo = new DataTypeRepository(db)
535
+ const nodes = repo.nodes
536
+ const objectType = ObjectTypes.DataType
537
+ const folders = new FolderRepository(db, ObjectTypes.DataTypeContainer, objectType)
538
+ const files = schemaFiles ? createSchemaFileWriter(db, schemaFiles) : undefined
539
+ const rewrite = () => files?.rewriteAll() ?? Promise.resolve()
540
+ const tree = createSettingsTree(
541
+ nodes,
542
+ ObjectTypes.DataTypeContainer,
543
+ objectType,
544
+ async (item) => {
545
+ const model = await repo.byKey(item.key)
546
+ item.icon = 'icon-autofill'
547
+ item.editorUiAlias = model?.editorUiAlias ?? null
548
+ },
549
+ )
550
+ const shape = (rows: readonly NodeRow[]) =>
551
+ toTreeItems(nodes, rows, objectType, () => 'icon-autofill')
552
+
553
+ /** A data type saved in the backoffice gets a file alias from its name, once. */
554
+ const withAlias = async (model: DataTypeModel): Promise<DataTypeModel> => {
555
+ const existing = await repo.byKey(model.key)
556
+ if (existing?.alias) return { ...model, alias: existing.alias }
557
+ if (model.alias) return model
558
+ const base = aliasFromName(model.name)
559
+ let alias = base
560
+ for (let i = 2; await repo.byAlias(alias); i++) alias = `${base}${i}`
561
+ return { ...model, alias }
562
+ }
563
+
564
+ return underSchemaLock(
565
+ db,
566
+ {
567
+ byKey: (key) => repo.byKey(key),
568
+ byKeys: (keys) => repo.byKeys(keys),
569
+ async valueSchema(key) {
570
+ const model = await repo.byKey(key)
571
+ if (!model) return undefined
572
+ return {
573
+ valueTypeName: valueTypeName(model.dbType),
574
+ jsonSchema: schemaForStorage(model.dbType),
575
+ }
576
+ },
577
+ async save(model: DataTypeModel) {
578
+ const saved = await repo.save(await withAlias(model))
579
+ await files?.writeDataType(saved)
580
+ },
581
+ async remove(key) {
582
+ const before = await repo.byKey(key)
583
+ if (!before) return false
584
+ if ((await repo.referencedBy(key)).length > 0) return 'in-use'
585
+ const removed = await repo.delete(key)
586
+ if (removed && before.alias) await files?.removeDataType(before.alias)
587
+ return removed
588
+ },
589
+ filter: (criteria, paging) => repo.filter(criteria, paging),
590
+ search: (query, paging) => repo.filter({ name: query }, paging),
591
+ referencedBy: (key) => repo.referencedBy(key),
592
+ async move(key, parentKey) {
593
+ const moved = await repo.move(key, parentKey)
594
+ if (moved) await rewrite()
595
+ return moved
596
+ },
597
+ async copy(key, parentKey) {
598
+ const copy = await repo.copy(key, parentKey)
599
+ if (!copy) return undefined
600
+ const aliased = await repo.save(await withAlias(copy))
601
+ await files?.writeDataType(aliased)
602
+ return aliased
603
+ },
604
+ folders: underSchemaLock(db, createFolderPort(folders, tree, rewrite), [
605
+ 'createFolder',
606
+ 'renameFolder',
607
+ 'deleteFolder',
608
+ ]),
609
+ treeRoot: tree.treeRoot,
610
+ treeChildren: tree.treeChildren,
611
+
612
+ async root(paging) {
613
+ const page = await nodes.roots(objectType, paging.skip, paging.take)
614
+ return { total: page.total, items: await shape(page.items) }
615
+ },
616
+ async children(parentKey, paging) {
617
+ const parent = await nodes.byKey(parentKey)
618
+ if (!parent) return { total: 0, items: [] }
619
+ const page = await nodes.children(parent.id, objectType, paging.skip, paging.take)
620
+ return { total: page.total, items: await shape(page.items) }
621
+ },
622
+ ancestors: tree.ancestors,
623
+ items: tree.items,
624
+ },
625
+ ['save', 'remove', 'move', 'copy'],
626
+ )
627
+ }
628
+
629
+ function toIdentifier(value: string): string {
630
+ const cleaned = value.replace(/[^A-Za-z0-9]+(.)?/g, (_, next: string | undefined) =>
631
+ next ? next.toUpperCase() : '',
632
+ )
633
+ const identifier = cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
634
+ return /^[A-Za-z]/.test(identifier) ? identifier : `Template${identifier}`
635
+ }
636
+
637
+ /** The starter view a new template gets, mirroring Umbraco's scaffold. */
638
+ export function scaffoldTemplate(
639
+ name: string,
640
+ alias: string,
641
+ layout: string | null = null,
642
+ ): string {
643
+ const layoutLine = layout ? `export const layout = '${layout}'\n\n` : ''
644
+ return `import type { PageProps } from '@crombolo/crombolo'
645
+
646
+ ${layoutLine}export default function ${toIdentifier(name || alias)}({ model }: PageProps) {
647
+ return (
648
+ <div>
649
+ <h1>{model.name}</h1>
650
+ </div>
651
+ )
652
+ }
653
+ `
654
+ }
655
+
656
+ const RAZOR_LAYOUT_BLOCK = /^\s*@\{\s*Layout\s*=\s*(null|"([^"]*)")\s*;\s*\}\s*/
657
+ const RAZOR_PREAMBLE = /^\s*(?:@(?:using|inherits)\b[^\n]*\n\s*)*/
658
+ const LAYOUT_EXPORT = /^export\s+const\s+layout\s*=\s*['"][^'"]*['"]\s*;?[ \t]*\n*/m
659
+
660
+ /**
661
+ * The template editor is Umbraco's, and speaks Razor: a new template starts as
662
+ * its Razor scaffold, and picking a master template prepends a
663
+ * `@{ Layout = "x.cshtml"; }` block (`"undefined.cshtml"` when the master is
664
+ * removed from a view that has no such block). Views here are TSX, so the
665
+ * untouched scaffold becomes the TSX one, and a layout block becomes the view's
666
+ * `export const layout`. Anything else is saved as written.
667
+ */
668
+ export function fromBackofficeTemplate(content: string, name: string, alias: string): string {
669
+ const afterPreamble = content.replace(RAZOR_PREAMBLE, '')
670
+ const block = RAZOR_LAYOUT_BLOCK.exec(afterPreamble)
671
+ if (!block) return content
672
+ const target = block[2]?.replace(/\.cshtml$/i, '')
673
+ const layout = target && target !== 'undefined' ? target : null
674
+ const rest = afterPreamble.slice(block[0].length)
675
+ if (rest.trim() === '') return scaffoldTemplate(name, alias, layout)
676
+ const withoutLayout = rest.replace(LAYOUT_EXPORT, '')
677
+ return layout ? `export const layout = '${layout}'\n\n${withoutLayout}` : withoutLayout
678
+ }
679
+
680
+ /** The layout a view names, which the template tree shows as its master. */
681
+ export function layoutAliasOf(content: string | null): string | null {
682
+ const match = /export\s+const\s+layout\s*=\s*['"]([^'"]+)['"]/.exec(content ?? '')
683
+ return match?.[1] ?? null
684
+ }
685
+
686
+ export function createTemplatePort(db: Db, files: ComponentFileStore): TemplatePort {
687
+ const repo = new ComponentRepository(db, files)
688
+
689
+ /** Every template with its master resolved from the view, one pass. */
690
+ const withMasters = async (): Promise<ComponentModel[]> => {
691
+ const all = await repo.all()
692
+ const byAlias = new Map(all.map((t) => [t.alias, t]))
693
+ return all.map((t) => {
694
+ const layout = layoutAliasOf(t.content)
695
+ return { ...t, masterKey: layout ? (byAlias.get(layout)?.key ?? null) : null }
696
+ })
697
+ }
698
+ const toItem = (t: ComponentModel, all: readonly ComponentModel[]): TreeItem => ({
699
+ key: t.key,
700
+ name: t.name,
701
+ hasChildren: all.some((o) => o.masterKey === t.key),
702
+ parentKey: t.masterKey ?? null,
703
+ icon: 'icon-layout',
704
+ isFolder: false,
705
+ })
706
+ const pageOf = (items: TreeItem[], paging: SkipTake): Page<TreeItem> => ({
707
+ total: items.length,
708
+ items: items.slice(paging.skip, paging.skip + paging.take),
709
+ })
710
+
711
+ return {
712
+ async byKey(key) {
713
+ const model = await repo.byKey(key)
714
+ if (!model) return undefined
715
+ const layout = layoutAliasOf(model.content)
716
+ const master = layout ? await repo.byAlias(layout) : undefined
717
+ return { ...model, masterKey: master?.key ?? null }
718
+ },
719
+ async save(model: ComponentModel) {
720
+ await repo.save({
721
+ ...model,
722
+ content:
723
+ model.content === null
724
+ ? model.content
725
+ : fromBackofficeTemplate(model.content, model.name, model.alias),
726
+ })
727
+ },
728
+ remove: (key) => repo.delete(key),
729
+ scaffold: scaffoldTemplate,
730
+
731
+ async root(paging) {
732
+ const all = await withMasters()
733
+ return pageOf(
734
+ all.filter((t) => !t.masterKey).map((t) => toItem(t, all)),
735
+ paging,
736
+ )
737
+ },
738
+ async children(parentKey, paging) {
739
+ const all = await withMasters()
740
+ return pageOf(
741
+ all.filter((t) => t.masterKey === parentKey).map((t) => toItem(t, all)),
742
+ paging,
743
+ )
744
+ },
745
+ async ancestors(descendantKey) {
746
+ const all = await withMasters()
747
+ const chain: TreeItem[] = []
748
+ let current = all.find((t) => t.key === descendantKey)
749
+ while (current?.masterKey) {
750
+ const master = all.find((t) => t.key === current?.masterKey)
751
+ if (!master) break
752
+ chain.unshift(toItem(master, all))
753
+ current = master
754
+ }
755
+ return chain
756
+ },
757
+ async siblings(target, before, after) {
758
+ const all = await withMasters()
759
+ const self = all.find((t) => t.key === target)
760
+ if (!self) return undefined
761
+ const group = all.filter((t) => (t.masterKey ?? null) === (self.masterKey ?? null))
762
+ const index = group.findIndex((t) => t.key === target)
763
+ const start = Math.max(0, index - before)
764
+ const end = Math.min(group.length, index + after + 1)
765
+ return {
766
+ items: group.slice(start, end).map((t) => toItem(t, all)),
767
+ totalBefore: start,
768
+ totalAfter: group.length - end,
769
+ }
770
+ },
771
+ async search(query, paging) {
772
+ const q = query.toLowerCase()
773
+ const all = (await withMasters()).filter(
774
+ (t) => t.name.toLowerCase().includes(q) || t.alias.toLowerCase().includes(q),
775
+ )
776
+ return { total: all.length, items: all.slice(paging.skip, paging.skip + paging.take) }
777
+ },
778
+ async items(keys) {
779
+ const all = await withMasters()
780
+ return all.filter((t) => keys.includes(t.key)).map((t) => toItem(t, all))
781
+ },
782
+ }
783
+ }