@crombolo/import-umbraco 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/src/schema.ts ADDED
@@ -0,0 +1,641 @@
1
+ /**
2
+ * Umbraco's definitions → the schema-as-code model: document, media and member
3
+ * types, data types and languages, as `@crombolo/schema` writes them to TOML.
4
+ *
5
+ * Keys are carried over unchanged, which is what lets the content bundle name a
6
+ * content type by key and find it after the schema has been synced.
7
+ */
8
+ import { ObjectTypes } from '@crombolo/core'
9
+ import { BUILT_IN_MEDIA_TYPES, DEFAULT_DATA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
10
+ import {
11
+ aliasFromName,
12
+ BUILTIN_DATA_TYPES,
13
+ builtInMediaTypeSchema,
14
+ type SchemaContentType,
15
+ type SchemaDataType,
16
+ type SchemaGroup,
17
+ type SchemaLanguage,
18
+ type SchemaProperty,
19
+ type SchemaSet,
20
+ type SchemaTypeKind,
21
+ validateSchemaSet,
22
+ writeMediaType,
23
+ } from '@crombolo/schema'
24
+ import type { Findings } from './findings.ts'
25
+ import { key, type Source } from './source.ts'
26
+
27
+ /** The property editors crombolo has an editor and a value converter for. */
28
+ export const SUPPORTED_EDITORS: ReadonlySet<string> = new Set([
29
+ ...DEFAULT_DATA_TYPES.map((dataType) => dataType.editorAlias),
30
+ 'Umbraco.BlockList',
31
+ 'Umbraco.BlockGrid',
32
+ 'Umbraco.Decimal',
33
+ 'Umbraco.ElementPicker',
34
+ 'Umbraco.EmailAddress',
35
+ 'Umbraco.MultiNodeTreePicker',
36
+ 'Umbraco.MultipleTextstring',
37
+ 'Umbraco.Slider',
38
+ ])
39
+
40
+ const TINY_MCE_UI = 'Umb.PropertyEditorUi.TinyMCE'
41
+ const TIPTAP_UI = 'Umb.PropertyEditorUi.Tiptap'
42
+
43
+ export interface SourceDataType {
44
+ id: number
45
+ key: string
46
+ name: string
47
+ alias: string
48
+ editor: string
49
+ dbType: string
50
+ supported: boolean
51
+ }
52
+
53
+ export interface SourceContentType {
54
+ id: number
55
+ key: string
56
+ alias: string
57
+ name: string
58
+ kind: SchemaTypeKind
59
+ isElement: boolean
60
+ variesByCulture: boolean
61
+ }
62
+
63
+ export interface SourcePropertyType {
64
+ id: number
65
+ alias: string
66
+ contentTypeId: number
67
+ dataType: SourceDataType
68
+ }
69
+
70
+ export interface SourceTemplate {
71
+ id: number
72
+ key: string
73
+ alias: string
74
+ name: string
75
+ /** The layout it renders inside, by alias. */
76
+ layout?: string
77
+ }
78
+
79
+ export interface SourceLanguage {
80
+ id: number
81
+ iso: string
82
+ }
83
+
84
+ export interface ConvertedSchema {
85
+ set: SchemaSet
86
+ dataTypes: Map<number, SourceDataType>
87
+ contentTypes: Map<number, SourceContentType>
88
+ propertyTypes: Map<number, SourcePropertyType>
89
+ templates: Map<number, SourceTemplate>
90
+ languages: Map<number, SourceLanguage>
91
+ }
92
+
93
+ const KIND_OF: Record<string, SchemaTypeKind> = {
94
+ [ObjectTypes.DocumentType]: 'document',
95
+ [ObjectTypes.MediaType]: 'media',
96
+ [ObjectTypes.MemberType]: 'member',
97
+ }
98
+
99
+ const CONTAINERS: ReadonlySet<string> = new Set([
100
+ ObjectTypes.DocumentTypeContainer,
101
+ ObjectTypes.MediaTypeContainer,
102
+ ObjectTypes.MemberTypeContainer,
103
+ ObjectTypes.DataTypeContainer,
104
+ ])
105
+
106
+ const truthy = (value: unknown): boolean => value === 1 || value === true || value === '1'
107
+ const text = (value: unknown): string | undefined =>
108
+ value === null || value === undefined || value === '' ? undefined : String(value)
109
+
110
+ function parseConfig(raw: unknown): Record<string, unknown> {
111
+ if (typeof raw !== 'string' || !raw) return {}
112
+ try {
113
+ const parsed = JSON.parse(raw)
114
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
115
+ } catch {
116
+ return {}
117
+ }
118
+ }
119
+
120
+ const sortedJson = (config: Record<string, unknown>): string =>
121
+ JSON.stringify(Object.fromEntries(Object.entries(config).sort(([a], [b]) => a.localeCompare(b))))
122
+
123
+ /** `A/B` for a node inside folders, from the container nodes above it. */
124
+ function folderResolver(source: Source): (parentId: number) => string | undefined {
125
+ const containers = new Map(
126
+ source
127
+ .all<{ id: number; parentId: number; name: string | null; objectType: string | null }>(
128
+ 'SELECT id, parentId, "text" AS name, nodeObjectType AS objectType FROM umbracoNode',
129
+ )
130
+ .filter((row) => CONTAINERS.has(key(row.objectType)))
131
+ .map((row) => [Number(row.id), row]),
132
+ )
133
+ return (parentId) => {
134
+ const path: string[] = []
135
+ for (let at = containers.get(parentId), depth = 0; at && depth < 32; depth++) {
136
+ path.unshift(at.name ?? '')
137
+ at = containers.get(Number(at.parentId))
138
+ }
139
+ return path.length > 0 ? path.join('/') : undefined
140
+ }
141
+ }
142
+
143
+ function readDataTypes(
144
+ source: Source,
145
+ findings: Findings,
146
+ folderOf: (parentId: number) => string | undefined,
147
+ ): { byId: Map<number, SourceDataType>; files: SchemaDataType[] } {
148
+ const builtinAliasByKey = new Map(DEFAULT_DATA_TYPES.map((d) => [d.key.toLowerCase(), d.alias]))
149
+ const taken = new Set(BUILTIN_DATA_TYPES.map((d) => d.alias))
150
+ const byId = new Map<number, SourceDataType>()
151
+ const files: SchemaDataType[] = []
152
+ const tinyMce: string[] = []
153
+
154
+ const rows = source.all<{
155
+ id: number
156
+ editor: string
157
+ editorUi: string | null
158
+ dbType: string
159
+ config: string | null
160
+ key: string
161
+ name: string | null
162
+ parentId: number
163
+ }>(
164
+ `SELECT d.nodeId AS id, d.propertyEditorAlias AS editor, d.propertyEditorUiAlias AS editorUi,
165
+ d.dbType AS dbType, d.config AS config, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId
166
+ FROM umbracoDataType d JOIN umbracoNode n ON n.id = d.nodeId ORDER BY d.nodeId`,
167
+ )
168
+ for (const row of rows) {
169
+ const name = row.name ?? row.editor
170
+ let alias = builtinAliasByKey.get(key(row.key))
171
+ if (!alias) {
172
+ alias = aliasFromName(name)
173
+ for (let n = 2, base = alias; taken.has(alias); n++) alias = `${base}${n}`
174
+ taken.add(alias)
175
+ }
176
+
177
+ let editorUi = text(row.editorUi)
178
+ if (editorUi === TINY_MCE_UI) {
179
+ // Umbraco 16 replaced TinyMCE with Tiptap; the stored markup is the same.
180
+ editorUi = TIPTAP_UI
181
+ tinyMce.push(name)
182
+ }
183
+
184
+ const dataType: SchemaDataType = {
185
+ key: key(row.key),
186
+ alias,
187
+ name,
188
+ editor: row.editor,
189
+ config: parseConfig(row.config),
190
+ }
191
+ if (editorUi) dataType.editorUi = editorUi
192
+ const folder = folderOf(Number(row.parentId))
193
+ if (folder) dataType.folder = folder
194
+
195
+ byId.set(Number(row.id), {
196
+ id: Number(row.id),
197
+ key: key(row.key),
198
+ name,
199
+ alias,
200
+ editor: row.editor,
201
+ dbType: row.dbType,
202
+ supported: SUPPORTED_EDITORS.has(row.editor),
203
+ })
204
+
205
+ // A built-in needs no file unless the site changed its editor or configuration.
206
+ const builtin = BUILTIN_DATA_TYPES.find((b) => b.alias === alias)
207
+ const unchanged =
208
+ builtin !== undefined &&
209
+ builtin.editor === dataType.editor &&
210
+ sortedJson(builtin.config) === sortedJson(dataType.config)
211
+ if (!unchanged) files.push(dataType)
212
+ }
213
+
214
+ findings.some({
215
+ class: 'needs-a-person',
216
+ code: 'tinymce-data-types',
217
+ title: 'Rich text editors configured for TinyMCE',
218
+ detail:
219
+ 'These now use the Tiptap editor. Stored content is unchanged, but each toolbar is TinyMCE’s and needs setting up again.',
220
+ count: tinyMce.length,
221
+ items: tinyMce,
222
+ })
223
+ return { byId, files }
224
+ }
225
+
226
+ /** A media type as text, with the property keys taken out, to compare two definitions by meaning. */
227
+ function shapeOf(type: SchemaContentType): string {
228
+ const strip = (properties: SchemaProperty[]) => properties.map(({ key: _, ...rest }) => rest)
229
+ return writeMediaType({
230
+ ...type,
231
+ properties: strip(type.properties),
232
+ groups: type.groups?.map((g) => ({ ...g, properties: strip(g.properties) })),
233
+ tabs: type.tabs.map((tab) => ({
234
+ ...tab,
235
+ properties: strip(tab.properties),
236
+ groups: tab.groups.map((g) => ({ ...g, properties: strip(g.properties) })),
237
+ })),
238
+ })
239
+ }
240
+
241
+ interface ContentTypeRow {
242
+ id: number
243
+ alias: string
244
+ icon: string | null
245
+ description: string | null
246
+ isElement: unknown
247
+ allowAtRoot: unknown
248
+ variations: number
249
+ listView: string | null
250
+ key: string
251
+ name: string | null
252
+ parentId: number
253
+ objectType: string
254
+ }
255
+
256
+ export function convertSchema(source: Source, findings: Findings): ConvertedSchema {
257
+ const folderOf = folderResolver(source)
258
+ const { byId: dataTypes, files: dataTypeFiles } = readDataTypes(source, findings, folderOf)
259
+ const dataTypeAliasByKey = new Map([...dataTypes.values()].map((d) => [d.key, d.alias]))
260
+
261
+ // Languages
262
+ const languageRows = source.all<{
263
+ id: number
264
+ iso: string
265
+ name: string | null
266
+ isDefault: unknown
267
+ mandatory: unknown
268
+ fallbackId: number | null
269
+ }>(
270
+ `SELECT id, languageISOCode AS iso, languageCultureName AS name, isDefaultVariantLang AS isDefault,
271
+ mandatory, fallbackLanguageId AS fallbackId FROM umbracoLanguage ORDER BY id`,
272
+ )
273
+ const languages = new Map(
274
+ languageRows.map((l) => [Number(l.id), { id: Number(l.id), iso: l.iso }]),
275
+ )
276
+ const schemaLanguages: SchemaLanguage[] = languageRows.map((l) => {
277
+ const language: SchemaLanguage = {
278
+ iso: l.iso,
279
+ name: l.name ?? l.iso,
280
+ default: truthy(l.isDefault),
281
+ mandatory: truthy(l.mandatory),
282
+ }
283
+ const fallback = l.fallbackId === null ? undefined : languages.get(Number(l.fallbackId))?.iso
284
+ if (fallback) language.fallback = fallback
285
+ return language
286
+ })
287
+
288
+ // Templates, with the layout each renders inside
289
+ const templateRows = source.all<{
290
+ id: number
291
+ alias: string
292
+ key: string
293
+ name: string | null
294
+ parentId: number
295
+ }>(
296
+ `SELECT t.nodeId AS id, t.alias AS alias, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId
297
+ FROM cmsTemplate t JOIN umbracoNode n ON n.id = t.nodeId ORDER BY t.nodeId`,
298
+ )
299
+ const templates = new Map<number, SourceTemplate>(
300
+ templateRows.map((t) => [
301
+ Number(t.id),
302
+ { id: Number(t.id), key: key(t.key), alias: t.alias, name: t.name ?? t.alias },
303
+ ]),
304
+ )
305
+ for (const row of templateRows) {
306
+ const layout = templates.get(Number(row.parentId))
307
+ const template = templates.get(Number(row.id))
308
+ if (layout && template) template.layout = layout.alias
309
+ }
310
+
311
+ // Content types
312
+ const typeRows = source
313
+ .all<ContentTypeRow>(
314
+ `SELECT c.nodeId AS id, c.alias AS alias, c.icon AS icon, c.description AS description,
315
+ c.isElement AS isElement, c.allowAtRoot AS allowAtRoot, c.variations AS variations,
316
+ c.listView AS listView, n.uniqueId AS key, n."text" AS name, n.parentId AS parentId,
317
+ n.nodeObjectType AS objectType
318
+ FROM cmsContentType c JOIN umbracoNode n ON n.id = c.nodeId ORDER BY c.nodeId`,
319
+ )
320
+ .filter((row) => KIND_OF[key(row.objectType)] !== undefined)
321
+
322
+ const contentTypes = new Map<number, SourceContentType>(
323
+ typeRows.map((row) => [
324
+ Number(row.id),
325
+ {
326
+ id: Number(row.id),
327
+ key: key(row.key),
328
+ alias: row.alias,
329
+ name: row.name ?? row.alias,
330
+ kind: KIND_OF[key(row.objectType)] as SchemaTypeKind,
331
+ isElement: truthy(row.isElement),
332
+ variesByCulture: (Number(row.variations) & 1) === 1,
333
+ },
334
+ ]),
335
+ )
336
+ const aliasOf = (id: number, kind: SchemaTypeKind) => {
337
+ const type = contentTypes.get(id)
338
+ return type && type.kind === kind ? type.alias : undefined
339
+ }
340
+
341
+ const groups = source.all<{
342
+ id: number
343
+ typeId: number
344
+ type: number
345
+ name: string | null
346
+ alias: string | null
347
+ sortOrder: number
348
+ }>(
349
+ `SELECT id, contenttypeNodeId AS typeId, "type" AS type, "text" AS name, alias, sortorder AS sortOrder
350
+ FROM cmsPropertyTypeGroup ORDER BY sortorder, id`,
351
+ )
352
+ const memberFlags = source.has('cmsMemberType')
353
+ ? new Map(
354
+ source
355
+ .all<{ propertyTypeId: number; canEdit: unknown; canView: unknown; sensitive: unknown }>(
356
+ 'SELECT propertytypeId AS propertyTypeId, memberCanEdit AS canEdit, viewOnProfile AS canView, isSensitive AS sensitive FROM cmsMemberType',
357
+ )
358
+ .map((row) => [Number(row.propertyTypeId), row]),
359
+ )
360
+ : new Map()
361
+
362
+ const propertyRows = source.all<{
363
+ id: number
364
+ dataTypeId: number
365
+ typeId: number
366
+ groupId: number | null
367
+ alias: string
368
+ name: string | null
369
+ sortOrder: number
370
+ mandatory: unknown
371
+ mandatoryMessage: string | null
372
+ regex: string | null
373
+ regexMessage: string | null
374
+ description: string | null
375
+ labelOnTop: unknown
376
+ variations: number
377
+ key: string
378
+ }>(
379
+ `SELECT id, dataTypeId, contentTypeId AS typeId, propertyTypeGroupId AS groupId, Alias AS alias,
380
+ Name AS name, sortOrder, mandatory, mandatoryMessage, validationRegExp AS regex,
381
+ validationRegExpMessage AS regexMessage, Description AS description, labelOnTop,
382
+ variations, UniqueID AS key
383
+ FROM cmsPropertyType ORDER BY sortOrder, id`,
384
+ )
385
+
386
+ const propertyTypes = new Map<number, SourcePropertyType>()
387
+ const propertiesByGroup = new Map<string, SchemaProperty[]>()
388
+ const unsupported = new Map<string, string[]>()
389
+ const orphaned: string[] = []
390
+ for (const row of propertyRows) {
391
+ const type = contentTypes.get(Number(row.typeId))
392
+ if (!type) continue
393
+ const dataType = dataTypes.get(Number(row.dataTypeId))
394
+ if (!dataType) {
395
+ orphaned.push(`${type.alias}.${row.alias}`)
396
+ continue
397
+ }
398
+ propertyTypes.set(Number(row.id), {
399
+ id: Number(row.id),
400
+ alias: row.alias,
401
+ contentTypeId: type.id,
402
+ dataType,
403
+ })
404
+ if (!dataType.supported) {
405
+ const users = unsupported.get(dataType.editor) ?? []
406
+ users.push(`${type.alias}.${row.alias}`)
407
+ unsupported.set(dataType.editor, users)
408
+ }
409
+
410
+ const property: SchemaProperty = {
411
+ key: key(row.key),
412
+ alias: row.alias,
413
+ name: row.name ?? row.alias,
414
+ type: dataType.alias,
415
+ mandatory: truthy(row.mandatory),
416
+ // The validator refuses a varying property on a type that does not vary.
417
+ variesByCulture: type.variesByCulture && (Number(row.variations) & 1) === 1,
418
+ variesBySegment: (Number(row.variations) & 2) === 2,
419
+ labelOnTop: truthy(row.labelOnTop),
420
+ }
421
+ const description = text(row.description)
422
+ if (description) property.description = description
423
+ const mandatoryMessage = text(row.mandatoryMessage)
424
+ if (mandatoryMessage) property.mandatoryMessage = mandatoryMessage
425
+ const regex = text(row.regex)
426
+ if (regex) property.regex = regex
427
+ const regexMessage = text(row.regexMessage)
428
+ if (regexMessage) property.regexMessage = regexMessage
429
+ if (type.kind === 'member') {
430
+ const flags = memberFlags.get(Number(row.id))
431
+ if (truthy(flags?.canView)) property.memberCanView = true
432
+ if (truthy(flags?.canEdit)) property.memberCanEdit = true
433
+ if (truthy(flags?.sensitive)) property.sensitive = true
434
+ }
435
+
436
+ const slot = `${type.id}:${row.groupId ?? ''}`
437
+ const list = propertiesByGroup.get(slot) ?? []
438
+ list.push(property)
439
+ propertiesByGroup.set(slot, list)
440
+ }
441
+
442
+ const compositions = source.all<{ parent: number; child: number }>(
443
+ 'SELECT parentContentTypeId AS parent, childContentTypeId AS child FROM cmsContentType2ContentType',
444
+ )
445
+ const allowed = source.all<{ id: number; allowed: number; sortOrder: number }>(
446
+ 'SELECT Id AS id, AllowedId AS allowed, SortOrder AS sortOrder FROM cmsContentTypeAllowedContentType ORDER BY SortOrder',
447
+ )
448
+ const typeTemplates = source.all<{ typeId: number; templateId: number; isDefault: unknown }>(
449
+ 'SELECT contentTypeNodeId AS typeId, templateNodeId AS templateId, IsDefault AS isDefault FROM cmsDocumentType',
450
+ )
451
+ const cleanup = source.has('umbracoContentVersionCleanupPolicy')
452
+ ? new Map(
453
+ source
454
+ .all<{ typeId: number; prevent: unknown; newer: number | null; perDay: number | null }>(
455
+ `SELECT contentTypeId AS typeId, preventCleanup AS prevent,
456
+ keepAllVersionsNewerThanDays AS newer, keepLatestVersionPerDayForDays AS perDay
457
+ FROM umbracoContentVersionCleanupPolicy`,
458
+ )
459
+ .map((row) => [Number(row.typeId), row]),
460
+ )
461
+ : new Map()
462
+
463
+ const schemaContentTypes: SchemaContentType[] = []
464
+ const mediaTypes: SchemaContentType[] = []
465
+ const memberTypes: SchemaContentType[] = []
466
+ const strippedElements: string[] = []
467
+
468
+ for (const row of typeRows) {
469
+ const meta = contentTypes.get(Number(row.id)) as SourceContentType
470
+ const kind = meta.kind
471
+ const type: SchemaContentType = {
472
+ key: meta.key,
473
+ alias: meta.alias,
474
+ name: meta.name,
475
+ icon: text(row.icon) ?? 'icon-document',
476
+ allowAtRoot: truthy(row.allowAtRoot),
477
+ isElement: meta.isElement,
478
+ allowInLibrary: false,
479
+ variesByCulture: meta.variesByCulture,
480
+ variesBySegment: (Number(row.variations) & 2) === 2,
481
+ compositions: compositions
482
+ .filter((c) => Number(c.child) === meta.id)
483
+ .flatMap((c) => aliasOf(Number(c.parent), kind) ?? []),
484
+ allowChildren: allowed
485
+ .filter((a) => Number(a.id) === meta.id)
486
+ .flatMap((a) => aliasOf(Number(a.allowed), kind) ?? []),
487
+ components: [],
488
+ cleanup: { prevent: false },
489
+ properties: propertiesByGroup.get(`${meta.id}:`) ?? [],
490
+ tabs: [],
491
+ }
492
+ const description = text(row.description)
493
+ if (description) type.description = description
494
+ const folder = folderOf(Number(row.parentId))
495
+ if (folder) type.folder = folder
496
+ const collection = row.listView ? dataTypeAliasByKey.get(key(row.listView)) : undefined
497
+ if (collection) type.collection = collection
498
+
499
+ if (kind === 'document') {
500
+ for (const link of typeTemplates.filter((t) => Number(t.typeId) === meta.id)) {
501
+ const template = templates.get(Number(link.templateId))
502
+ if (!template) continue
503
+ type.components.push(template.alias)
504
+ if (truthy(link.isDefault)) type.defaultComponent = template.alias
505
+ }
506
+ const policy = cleanup.get(meta.id)
507
+ if (policy) {
508
+ type.cleanup.prevent = truthy(policy.prevent)
509
+ if (policy.newer !== null) type.cleanup.keepAllNewerThanDays = Number(policy.newer)
510
+ if (policy.perDay !== null) type.cleanup.keepLatestPerDayForDays = Number(policy.perDay)
511
+ }
512
+ // An element type is a property bag with no URL; Umbraco tolerates route
513
+ // settings left on one, and the schema validator here does not.
514
+ if (type.isElement) {
515
+ if (type.allowAtRoot || type.components.length > 0 || type.allowChildren.length > 0)
516
+ strippedElements.push(type.alias)
517
+ type.allowAtRoot = false
518
+ type.components = []
519
+ type.defaultComponent = undefined
520
+ type.allowChildren = []
521
+ }
522
+ }
523
+
524
+ // Tabs and groups. A group inside a tab carries the tab's alias as a prefix.
525
+ const own = groups.filter((g) => Number(g.typeId) === meta.id)
526
+ const tabs = own.filter((g) => Number(g.type) === 1)
527
+ const tabAliases = new Set(tabs.map((tab) => tab.alias ?? ''))
528
+ const toGroup = (g: (typeof own)[number]): SchemaGroup => {
529
+ const group: SchemaGroup = {
530
+ name: g.name ?? '',
531
+ properties: propertiesByGroup.get(`${meta.id}:${g.id}`) ?? [],
532
+ }
533
+ const alias = g.alias?.split('/').at(-1)
534
+ if (alias) group.alias = alias
535
+ return group
536
+ }
537
+ const parentTab = (g: (typeof own)[number]) => {
538
+ const prefix = g.alias?.includes('/') ? g.alias.slice(0, g.alias.lastIndexOf('/')) : undefined
539
+ return prefix !== undefined && tabAliases.has(prefix) ? prefix : undefined
540
+ }
541
+ for (const tab of tabs) {
542
+ const entry: SchemaContentType['tabs'][number] = {
543
+ name: tab.name ?? '',
544
+ properties: propertiesByGroup.get(`${meta.id}:${tab.id}`) ?? [],
545
+ groups: own
546
+ .filter((g) => Number(g.type) === 0 && parentTab(g) === (tab.alias ?? ''))
547
+ .map(toGroup),
548
+ }
549
+ if (tab.alias) entry.alias = tab.alias
550
+ type.tabs.push(entry)
551
+ }
552
+ const orphanGroups = own.filter((g) => Number(g.type) === 0 && parentTab(g) === undefined)
553
+ if (orphanGroups.length > 0) type.groups = orphanGroups.map(toGroup)
554
+
555
+ if (kind === 'document') schemaContentTypes.push(type)
556
+ else if (kind === 'media') {
557
+ // A media type Umbraco ships is written only where the site changed it.
558
+ // Property keys are left out of the comparison: crombolo seeds its own.
559
+ const shipped = BUILT_IN_MEDIA_TYPES.find((t) => t.key === meta.key)
560
+ const present = new Set(
561
+ typeRows.filter((r) => KIND_OF[key(r.objectType)] === 'media').map((r) => r.alias),
562
+ )
563
+ if (!shipped || shapeOf(type) !== shapeOf(builtInMediaTypeSchema(shipped, present)))
564
+ mediaTypes.push(type)
565
+ } else {
566
+ type.components = []
567
+ type.allowChildren = []
568
+ const hasProperties =
569
+ type.properties.length > 0 || type.tabs.length > 0 || (type.groups ?? []).length > 0
570
+ if (meta.key !== SYSTEM_MEMBER_TYPE.key || hasProperties) memberTypes.push(type)
571
+ }
572
+ }
573
+
574
+ const set: SchemaSet = {
575
+ version: '1.0.0',
576
+ contentTypes: schemaContentTypes,
577
+ mediaTypes,
578
+ memberTypes,
579
+ dataTypes: dataTypeFiles,
580
+ languages: schemaLanguages,
581
+ }
582
+
583
+ findings.add({
584
+ class: 'migrates',
585
+ code: 'content-types',
586
+ title: 'Document, media and member types',
587
+ count: typeRows.length,
588
+ detail: `${schemaContentTypes.length} content types, ${mediaTypes.length} media types and ${memberTypes.length} member types are written as files; built-in types the site left alone need none.`,
589
+ })
590
+ findings.add({
591
+ class: 'migrates',
592
+ code: 'data-types',
593
+ title: 'Data types',
594
+ count: dataTypes.size,
595
+ detail: `${dataTypeFiles.length} are written as files; the rest are built in and unchanged.`,
596
+ })
597
+ findings.some({
598
+ class: 'migrates',
599
+ code: 'languages',
600
+ title: 'Languages',
601
+ count: schemaLanguages.length,
602
+ items: schemaLanguages.map((l) => l.iso),
603
+ })
604
+ for (const [editor, users] of unsupported) {
605
+ const names = [...dataTypes.values()].filter((d) => d.editor === editor).map((d) => d.name)
606
+ findings.add({
607
+ class: 'needs-a-person',
608
+ code: 'unsupported-editor',
609
+ title: `Property editor ${editor} has no equivalent here`,
610
+ detail: `Data types: ${names.join(', ')}. The stored values are carried across untouched, but the backoffice has no editor for them and templates receive the raw value.`,
611
+ count: users.length,
612
+ items: users,
613
+ })
614
+ }
615
+ findings.some({
616
+ class: 'needs-a-person',
617
+ code: 'element-route-settings',
618
+ title: 'Element types with templates, children or allow-at-root',
619
+ detail: 'An element type is never routed, so these settings were dropped from it.',
620
+ count: strippedElements.length,
621
+ items: strippedElements,
622
+ })
623
+ findings.some({
624
+ class: 'dropped',
625
+ code: 'orphaned-properties',
626
+ title: 'Properties whose data type no longer exists',
627
+ count: orphaned.length,
628
+ items: orphaned,
629
+ })
630
+
631
+ const componentAliases = new Set([...templates.values()].map((t) => t.alias))
632
+ for (const problem of validateSchemaSet(set, { componentAliases }))
633
+ findings.add({
634
+ class: 'blocking',
635
+ code: 'schema-invalid',
636
+ title: `The converted schema is not valid: ${problem.file}`,
637
+ detail: `${problem.path ? `${problem.path}: ` : ''}${problem.message}`,
638
+ })
639
+
640
+ return { set, dataTypes, contentTypes, propertyTypes, templates, languages }
641
+ }