@crombolo/import-umbraco 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/src/index.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Importing an Umbraco site: a compatibility report, and then the files of a
3
+ * crombolo site. `docs/umbraco-import.md`.
4
+ *
5
+ * Nothing here writes a database row or a file. The result is a report and a
6
+ * list of files — schema, a content bundle, view stubs — for the caller to put
7
+ * on disk, so the import itself is `crombolo start --bundle`, through the code
8
+ * that every other bundle goes through.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import {
13
+ fileNameFor,
14
+ writeContentType,
15
+ writeDataType,
16
+ writeLanguages,
17
+ writeMediaType,
18
+ writeMemberType,
19
+ writeSchemaVersion,
20
+ } from '@crombolo/schema'
21
+ import { writeBundle, writeBundlePackage } from '@crombolo/transfer'
22
+ import { convertContent } from './content.ts'
23
+ import { Findings } from './findings.ts'
24
+ import { type DictionaryItem, type DomainEntry, takeInventory } from './inventory.ts'
25
+ import { buildReport, type ImportReport, reportJson, reportMarkdown } from './report.ts'
26
+ import { convertSchema } from './schema.ts'
27
+ import { readSiteFiles, type SiteFiles } from './site-files.ts'
28
+ import { openSource } from './source.ts'
29
+ import { detectVersion, MINIMUM_MAJOR, NEWEST_MAJOR } from './version.ts'
30
+ import { componentPathFor, componentStub, viewStub } from './views.ts'
31
+
32
+ export { CLASS_ORDER, CLASS_TITLES, type Finding, type FindingClass } from './findings.ts'
33
+ export { type DictionaryItem, type DomainEntry, sourceUrls } from './inventory.ts'
34
+ export {
35
+ type ImportReport,
36
+ reportJson,
37
+ reportMarkdown,
38
+ reportSummary,
39
+ } from './report.ts'
40
+ export { SUPPORTED_EDITORS } from './schema.ts'
41
+ export { openSource, type Source } from './source.ts'
42
+ export {
43
+ type DetectedVersion,
44
+ detectVersion,
45
+ MINIMUM_MAJOR,
46
+ NEWEST_MAJOR,
47
+ UPGRADE_STATES,
48
+ versionForState,
49
+ } from './version.ts'
50
+ export { componentName, componentPathFor, componentStub, viewStub } from './views.ts'
51
+
52
+ /** Where the bundle goes in the new site, and what the start script names. */
53
+ export const IMPORT_BUNDLE = 'bundles/umbraco-import'
54
+ /** Where the report and everything a person has to act on are kept. */
55
+ export const IMPORT_DIR = 'import'
56
+
57
+ export interface ImportOptions {
58
+ /** The database backup: a `.bacpac`, or Umbraco's own SQLite file. */
59
+ source: string
60
+ /** The site's files — the web root or anything above it — when the backup has them. */
61
+ site?: string
62
+ /** Where media files are, when not under the site's `wwwroot/media`. */
63
+ media?: string
64
+ siteName?: string
65
+ /** Take each page's working copy rather than what is published. */
66
+ drafts?: boolean
67
+ /** Keep the staging database here instead of in a temporary directory. */
68
+ staging?: string
69
+ /**
70
+ * The crombolo release the new site runs, which its content package declares
71
+ * as its peer range. The importer does not know it itself: the CLI does.
72
+ */
73
+ coreVersion: string
74
+ }
75
+
76
+ /** One file of the new site: literal text, or a file to copy. */
77
+ export interface SiteFile {
78
+ /** Relative to the new site, `/`-separated. */
79
+ path: string
80
+ text?: string
81
+ bytes?: Uint8Array
82
+ copyFrom?: string
83
+ }
84
+
85
+ export interface ImportPlan {
86
+ report: ImportReport
87
+ siteName: string
88
+ /** Empty when the report has a blocking finding. */
89
+ files: SiteFile[]
90
+ dictionary: DictionaryItem[]
91
+ domains: DomainEntry[]
92
+ /** Media nodes whose file was not found; the import must be told to allow them. */
93
+ missingMedia: number
94
+ /** The paths the source site served, to check the imported site against. */
95
+ urls: string[]
96
+ }
97
+
98
+ export async function planImport(options: ImportOptions): Promise<ImportPlan> {
99
+ const source = await openSource(options.source, { staging: options.staging })
100
+ try {
101
+ const findings = new Findings()
102
+ const files: SiteFiles | undefined = options.site
103
+ ? readSiteFiles(options.site, options.media)
104
+ : options.media
105
+ ? {
106
+ root: '',
107
+ razor: [],
108
+ mediaDir: options.media,
109
+ packages: [],
110
+ plugins: [],
111
+ csharp: 0,
112
+ assets: [],
113
+ }
114
+ : undefined
115
+ const describe = () =>
116
+ buildReport(
117
+ {
118
+ path: options.source,
119
+ kind: source.kind,
120
+ exportedAt: source.exportedAt,
121
+ serverVersion: source.serverVersion,
122
+ siteFiles: options.site !== undefined,
123
+ },
124
+ umbraco,
125
+ findings.list,
126
+ )
127
+ const stopped = (): ImportPlan => ({
128
+ report: describe(),
129
+ siteName: options.siteName ?? 'Imported site',
130
+ files: [],
131
+ dictionary: [],
132
+ domains: [],
133
+ missingMedia: 0,
134
+ urls: [],
135
+ })
136
+
137
+ const umbraco = detectVersion(source)
138
+ if (umbraco.status === 'unknown') {
139
+ findings.add({
140
+ class: 'blocking',
141
+ code: 'not-umbraco',
142
+ title: 'This is not an Umbraco database the importer recognises',
143
+ detail: `It has no upgrade state for Umbraco 10 or later. The importer reads Umbraco ${MINIMUM_MAJOR} to ${NEWEST_MAJOR}; an older site has to be upgraded with Umbraco first.`,
144
+ })
145
+ return stopped()
146
+ }
147
+ if (umbraco.status === 'too-old') {
148
+ findings.add({
149
+ class: 'blocking',
150
+ code: 'version-too-old',
151
+ title: `Umbraco ${umbraco.version} is older than the importer supports`,
152
+ detail: `Upgrade the site to Umbraco ${MINIMUM_MAJOR} or later with Umbraco itself, export it again, then import. Umbraco’s own upgrader rewrites the stored content formats that changed in 14 and 15, and is the one tool certain to do it correctly.`,
153
+ })
154
+ return stopped()
155
+ }
156
+ if (umbraco.status === 'newer')
157
+ findings.add({
158
+ class: 'needs-a-person',
159
+ code: 'version-newer',
160
+ title: 'This Umbraco version is newer than the importer has been written against',
161
+ detail: `The importer knows Umbraco up to ${NEWEST_MAJOR}. It will try, but check the result carefully.`,
162
+ })
163
+
164
+ const schema = convertSchema(source, findings)
165
+ const siteName =
166
+ options.siteName ??
167
+ source.one<{ name: string | null }>(
168
+ `SELECT n."text" AS name FROM umbracoNode n
169
+ WHERE n.nodeObjectType = 'c66ba18e-eaf3-4cff-8a22-41b16d66a972' COLLATE NOCASE
170
+ AND n.parentId = -1 AND n.trashed = 0 ORDER BY n.sortOrder LIMIT 1`,
171
+ )?.name ??
172
+ 'Imported site'
173
+ const content = convertContent(source, schema, findings, {
174
+ snapshot: options.drafts ? 'drafts' : 'published',
175
+ siteName,
176
+ })
177
+ const inventory = takeInventory(source, schema, files, findings)
178
+
179
+ const out: SiteFile[] = []
180
+
181
+ // Schema
182
+ out.push({ path: 'schema/schema.toml', text: writeSchemaVersion(schema.set.version) })
183
+ for (const type of schema.set.contentTypes)
184
+ out.push({
185
+ path: `schema/content-types/${fileNameFor(type.alias)}`,
186
+ text: writeContentType(type),
187
+ })
188
+ for (const type of schema.set.mediaTypes ?? [])
189
+ out.push({
190
+ path: `schema/media-types/${fileNameFor(type.alias)}`,
191
+ text: writeMediaType(type),
192
+ })
193
+ for (const type of schema.set.memberTypes ?? [])
194
+ out.push({
195
+ path: `schema/member-types/${fileNameFor(type.alias)}`,
196
+ text: writeMemberType(type),
197
+ })
198
+ for (const dataType of schema.set.dataTypes)
199
+ out.push({
200
+ path: `schema/data-types/${fileNameFor(dataType.alias)}`,
201
+ text: writeDataType(dataType),
202
+ })
203
+ if (schema.set.languages.length > 0)
204
+ out.push({ path: 'schema/languages.toml', text: writeLanguages(schema.set.languages) })
205
+
206
+ // Views: a stub per template, and the Razor it stands in for.
207
+ const templates = [...schema.templates.values()]
208
+ const layouts = new Set(templates.flatMap((template) => template.layout ?? []))
209
+ const razorByAlias = new Map(
210
+ (files?.razor ?? [])
211
+ .filter((file) => /^Views\/[^/]+\.cshtml$/i.test(file.path))
212
+ .map((file) => [file.path.slice(6, -7).toLowerCase(), file]),
213
+ )
214
+ for (const template of templates) {
215
+ const original = razorByAlias.get(template.alias.toLowerCase())
216
+ out.push({
217
+ path: `components/${template.alias}.tsx`,
218
+ text: viewStub(template, {
219
+ isLayout: layouts.has(template.alias),
220
+ original: original ? `${IMPORT_DIR}/razor/${original.path}` : undefined,
221
+ }),
222
+ })
223
+ }
224
+ for (const razor of files?.razor ?? [])
225
+ out.push({ path: `${IMPORT_DIR}/razor/${razor.path}`, copyFrom: razor.file })
226
+ findings.some({
227
+ class: 'needs-a-person',
228
+ code: 'razor-templates',
229
+ title: 'Razor templates to rewrite as TSX',
230
+ detail:
231
+ 'Each has a stub in components/ that renders the page’s name inside the same layout chain, so the site boots and every page answers. The markup itself has to be rewritten.',
232
+ count: templates.length,
233
+ items: templates.map((template) => {
234
+ const original = razorByAlias.get(template.alias.toLowerCase())
235
+ if (!original) return template.alias
236
+ const uses = original.uses.length > 0 ? `, uses ${original.uses.join(', ')}` : ''
237
+ return `${template.alias} (${original.lines} lines${uses})`
238
+ }),
239
+ })
240
+ // Every other Razor view — partials, shared layouts, anything nested — is a
241
+ // component too. There is one root and one kind of file, so each gets a
242
+ // stub where it sat, rather than being left as reference material with
243
+ // nothing on disk for a template to import.
244
+ const others = (files?.razor ?? []).filter((file) => !/^Views\/[^/]+\.cshtml$/i.test(file.path))
245
+ for (const file of others)
246
+ out.push({
247
+ path: componentPathFor(file.path),
248
+ text: componentStub(file.path, `${IMPORT_DIR}/razor/${file.path}`),
249
+ })
250
+ findings.some({
251
+ class: 'needs-a-person',
252
+ code: 'razor-partials',
253
+ title: 'Razor views to rewrite as components',
254
+ detail:
255
+ `Each has a stub under components/, in the folder it sat in, so an import of it ` +
256
+ `resolves. The original is in ${IMPORT_DIR}/razor/ and the markup has to be rewritten.`,
257
+ count: others.length,
258
+ items: others.map((file) => `${file.path} (${file.lines} lines)`),
259
+ })
260
+
261
+ // Media files go straight into the site's media directory, under the keys
262
+ // the values name, so nothing has to hold a whole media library in memory.
263
+ const missing: string[] = []
264
+ const seen = new Set<string>()
265
+ for (const blob of content.blobs) {
266
+ if (seen.has(blob.key)) continue
267
+ seen.add(blob.key)
268
+ const file = files?.mediaDir ? join(files.mediaDir, blob.key) : undefined
269
+ if (file && existsSync(file)) out.push({ path: `media/${blob.key}`, copyFrom: file })
270
+ else missing.push(blob.key)
271
+ }
272
+ findings.some({
273
+ class: 'migrates',
274
+ code: 'media-files',
275
+ title: 'Media files',
276
+ count: seen.size - missing.length,
277
+ })
278
+ findings.some({
279
+ class: 'needs-a-person',
280
+ code: 'media-files-missing',
281
+ title: 'Media items with no file',
282
+ detail: files?.mediaDir
283
+ ? `Not found under ${files.mediaDir}. The media items are imported and will show as broken until their files are put in media/.`
284
+ : 'No media directory was given (--site or --media). The media items are imported and will show as broken until their files are put in media/.',
285
+ count: missing.length,
286
+ items: missing,
287
+ })
288
+
289
+ for (const asset of files?.assets ?? []) {
290
+ const [folder, ...rest] = asset.path.split('/')
291
+ out.push({
292
+ path: `${folder === 'css' ? 'css' : 'scripts'}/${rest.join('/')}`,
293
+ copyFrom: asset.file,
294
+ })
295
+ }
296
+ findings.some({
297
+ class: 'migrates',
298
+ code: 'assets',
299
+ title: 'Stylesheets and scripts',
300
+ count: files?.assets.length ?? 0,
301
+ })
302
+
303
+ // The content, as the private bundle package `content import` reads.
304
+ const imported = writeBundlePackage(writeBundle(content.set), {
305
+ name: 'umbraco-import',
306
+ version: '1.0.0',
307
+ id: 'umbraco-import',
308
+ label: `Content imported from ${siteName}`,
309
+ private: true,
310
+ peerRange: `^${options.coreVersion}`,
311
+ })
312
+ for (const file of imported)
313
+ out.push({ path: `${IMPORT_BUNDLE}/${file.path}`, text: file.text, bytes: file.bytes })
314
+
315
+ if (inventory.urls.length > 0)
316
+ out.push({ path: `${IMPORT_DIR}/urls.txt`, text: `${inventory.urls.join('\n')}\n` })
317
+ findings.some({
318
+ class: 'migrates',
319
+ code: 'urls',
320
+ title: 'Published URLs',
321
+ detail: `Listed in ${IMPORT_DIR}/urls.txt, to check the imported site against.`,
322
+ count: inventory.urls.length,
323
+ })
324
+
325
+ const report = describe()
326
+ out.push({ path: `${IMPORT_DIR}/report.md`, text: reportMarkdown(report) })
327
+ out.push({ path: `${IMPORT_DIR}/report.json`, text: reportJson(report) })
328
+
329
+ return {
330
+ report,
331
+ siteName,
332
+ files: report.ready ? out : [],
333
+ dictionary: inventory.dictionary,
334
+ domains: inventory.domains,
335
+ missingMedia: missing.length,
336
+ urls: inventory.urls,
337
+ }
338
+ } finally {
339
+ source.close()
340
+ }
341
+ }