@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/index.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Importing an Umbraco site: a compatibility report, and then the files of a
3
+ * crombolo site. `docs/16-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 } 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
+
71
+ /** One file of the new site: literal text, or a file to copy. */
72
+ export interface SiteFile {
73
+ /** Relative to the new site, `/`-separated. */
74
+ path: string
75
+ text?: string
76
+ bytes?: Uint8Array
77
+ copyFrom?: string
78
+ }
79
+
80
+ export interface ImportPlan {
81
+ report: ImportReport
82
+ siteName: string
83
+ /** Empty when the report has a blocking finding. */
84
+ files: SiteFile[]
85
+ dictionary: DictionaryItem[]
86
+ domains: DomainEntry[]
87
+ /** Media nodes whose file was not found; the import must be told to allow them. */
88
+ missingMedia: number
89
+ /** The paths the source site served, to check the imported site against. */
90
+ urls: string[]
91
+ }
92
+
93
+ export async function planImport(options: ImportOptions): Promise<ImportPlan> {
94
+ const source = await openSource(options.source, { staging: options.staging })
95
+ try {
96
+ const findings = new Findings()
97
+ const files: SiteFiles | undefined = options.site
98
+ ? readSiteFiles(options.site, options.media)
99
+ : options.media
100
+ ? {
101
+ root: '',
102
+ razor: [],
103
+ mediaDir: options.media,
104
+ packages: [],
105
+ plugins: [],
106
+ csharp: 0,
107
+ assets: [],
108
+ }
109
+ : undefined
110
+ const describe = () =>
111
+ buildReport(
112
+ {
113
+ path: options.source,
114
+ kind: source.kind,
115
+ exportedAt: source.exportedAt,
116
+ serverVersion: source.serverVersion,
117
+ siteFiles: options.site !== undefined,
118
+ },
119
+ umbraco,
120
+ findings.list,
121
+ )
122
+ const stopped = (): ImportPlan => ({
123
+ report: describe(),
124
+ siteName: options.siteName ?? 'Imported site',
125
+ files: [],
126
+ dictionary: [],
127
+ domains: [],
128
+ missingMedia: 0,
129
+ urls: [],
130
+ })
131
+
132
+ const umbraco = detectVersion(source)
133
+ if (umbraco.status === 'unknown') {
134
+ findings.add({
135
+ class: 'blocking',
136
+ code: 'not-umbraco',
137
+ title: 'This is not an Umbraco database the importer recognises',
138
+ 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.`,
139
+ })
140
+ return stopped()
141
+ }
142
+ if (umbraco.status === 'too-old') {
143
+ findings.add({
144
+ class: 'blocking',
145
+ code: 'version-too-old',
146
+ title: `Umbraco ${umbraco.version} is older than the importer supports`,
147
+ 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.`,
148
+ })
149
+ return stopped()
150
+ }
151
+ if (umbraco.status === 'newer')
152
+ findings.add({
153
+ class: 'needs-a-person',
154
+ code: 'version-newer',
155
+ title: 'This Umbraco version is newer than the importer has been written against',
156
+ detail: `The importer knows Umbraco up to ${NEWEST_MAJOR}. It will try, but check the result carefully.`,
157
+ })
158
+
159
+ const schema = convertSchema(source, findings)
160
+ const siteName =
161
+ options.siteName ??
162
+ source.one<{ name: string | null }>(
163
+ `SELECT n."text" AS name FROM umbracoNode n
164
+ WHERE n.nodeObjectType = 'c66ba18e-eaf3-4cff-8a22-41b16d66a972' COLLATE NOCASE
165
+ AND n.parentId = -1 AND n.trashed = 0 ORDER BY n.sortOrder LIMIT 1`,
166
+ )?.name ??
167
+ 'Imported site'
168
+ const content = convertContent(source, schema, findings, {
169
+ snapshot: options.drafts ? 'drafts' : 'published',
170
+ siteName,
171
+ })
172
+ const inventory = takeInventory(source, schema, files, findings)
173
+
174
+ const out: SiteFile[] = []
175
+
176
+ // Schema
177
+ out.push({ path: 'schema/schema.toml', text: writeSchemaVersion(schema.set.version) })
178
+ for (const type of schema.set.contentTypes)
179
+ out.push({
180
+ path: `schema/content-types/${fileNameFor(type.alias)}`,
181
+ text: writeContentType(type),
182
+ })
183
+ for (const type of schema.set.mediaTypes ?? [])
184
+ out.push({
185
+ path: `schema/media-types/${fileNameFor(type.alias)}`,
186
+ text: writeMediaType(type),
187
+ })
188
+ for (const type of schema.set.memberTypes ?? [])
189
+ out.push({
190
+ path: `schema/member-types/${fileNameFor(type.alias)}`,
191
+ text: writeMemberType(type),
192
+ })
193
+ for (const dataType of schema.set.dataTypes)
194
+ out.push({
195
+ path: `schema/data-types/${fileNameFor(dataType.alias)}`,
196
+ text: writeDataType(dataType),
197
+ })
198
+ if (schema.set.languages.length > 0)
199
+ out.push({ path: 'schema/languages.toml', text: writeLanguages(schema.set.languages) })
200
+
201
+ // Views: a stub per template, and the Razor it stands in for.
202
+ const templates = [...schema.templates.values()]
203
+ const layouts = new Set(templates.flatMap((template) => template.layout ?? []))
204
+ const razorByAlias = new Map(
205
+ (files?.razor ?? [])
206
+ .filter((file) => /^Views\/[^/]+\.cshtml$/i.test(file.path))
207
+ .map((file) => [file.path.slice(6, -7).toLowerCase(), file]),
208
+ )
209
+ for (const template of templates) {
210
+ const original = razorByAlias.get(template.alias.toLowerCase())
211
+ out.push({
212
+ path: `components/${template.alias}.tsx`,
213
+ text: viewStub(template, {
214
+ isLayout: layouts.has(template.alias),
215
+ original: original ? `${IMPORT_DIR}/razor/${original.path}` : undefined,
216
+ }),
217
+ })
218
+ }
219
+ for (const razor of files?.razor ?? [])
220
+ out.push({ path: `${IMPORT_DIR}/razor/${razor.path}`, copyFrom: razor.file })
221
+ findings.some({
222
+ class: 'needs-a-person',
223
+ code: 'razor-templates',
224
+ title: 'Razor templates to rewrite as TSX',
225
+ detail:
226
+ '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.',
227
+ count: templates.length,
228
+ items: templates.map((template) => {
229
+ const original = razorByAlias.get(template.alias.toLowerCase())
230
+ if (!original) return template.alias
231
+ const uses = original.uses.length > 0 ? `, uses ${original.uses.join(', ')}` : ''
232
+ return `${template.alias} (${original.lines} lines${uses})`
233
+ }),
234
+ })
235
+ // Every other Razor view — partials, shared layouts, anything nested — is a
236
+ // component too. There is one root and one kind of file, so each gets a
237
+ // stub where it sat, rather than being left as reference material with
238
+ // nothing on disk for a template to import.
239
+ const others = (files?.razor ?? []).filter((file) => !/^Views\/[^/]+\.cshtml$/i.test(file.path))
240
+ for (const file of others)
241
+ out.push({
242
+ path: componentPathFor(file.path),
243
+ text: componentStub(file.path, `${IMPORT_DIR}/razor/${file.path}`),
244
+ })
245
+ findings.some({
246
+ class: 'needs-a-person',
247
+ code: 'razor-partials',
248
+ title: 'Razor views to rewrite as components',
249
+ detail:
250
+ `Each has a stub under components/, in the folder it sat in, so an import of it ` +
251
+ `resolves. The original is in ${IMPORT_DIR}/razor/ and the markup has to be rewritten.`,
252
+ count: others.length,
253
+ items: others.map((file) => `${file.path} (${file.lines} lines)`),
254
+ })
255
+
256
+ // Media files go straight into the site's media directory, under the keys
257
+ // the values name, so nothing has to hold a whole media library in memory.
258
+ const missing: string[] = []
259
+ const seen = new Set<string>()
260
+ for (const blob of content.blobs) {
261
+ if (seen.has(blob.key)) continue
262
+ seen.add(blob.key)
263
+ const file = files?.mediaDir ? join(files.mediaDir, blob.key) : undefined
264
+ if (file && existsSync(file)) out.push({ path: `media/${blob.key}`, copyFrom: file })
265
+ else missing.push(blob.key)
266
+ }
267
+ findings.some({
268
+ class: 'migrates',
269
+ code: 'media-files',
270
+ title: 'Media files',
271
+ count: seen.size - missing.length,
272
+ })
273
+ findings.some({
274
+ class: 'needs-a-person',
275
+ code: 'media-files-missing',
276
+ title: 'Media items with no file',
277
+ detail: files?.mediaDir
278
+ ? `Not found under ${files.mediaDir}. The media items are imported and will show as broken until their files are put in media/.`
279
+ : '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/.',
280
+ count: missing.length,
281
+ items: missing,
282
+ })
283
+
284
+ for (const asset of files?.assets ?? []) {
285
+ const [folder, ...rest] = asset.path.split('/')
286
+ out.push({
287
+ path: `${folder === 'css' ? 'css' : 'scripts'}/${rest.join('/')}`,
288
+ copyFrom: asset.file,
289
+ })
290
+ }
291
+ findings.some({
292
+ class: 'migrates',
293
+ code: 'assets',
294
+ title: 'Stylesheets and scripts',
295
+ count: files?.assets.length ?? 0,
296
+ })
297
+
298
+ // The bundle, in the format `content import` already reads.
299
+ for (const file of writeBundle(content.set))
300
+ out.push({ path: `${IMPORT_BUNDLE}/${file.path}`, text: file.text, bytes: file.bytes })
301
+
302
+ if (inventory.urls.length > 0)
303
+ out.push({ path: `${IMPORT_DIR}/urls.txt`, text: `${inventory.urls.join('\n')}\n` })
304
+ findings.some({
305
+ class: 'migrates',
306
+ code: 'urls',
307
+ title: 'Published URLs',
308
+ detail: `Listed in ${IMPORT_DIR}/urls.txt, to check the imported site against.`,
309
+ count: inventory.urls.length,
310
+ })
311
+
312
+ const report = describe()
313
+ out.push({ path: `${IMPORT_DIR}/report.md`, text: reportMarkdown(report) })
314
+ out.push({ path: `${IMPORT_DIR}/report.json`, text: reportJson(report) })
315
+
316
+ return {
317
+ report,
318
+ siteName,
319
+ files: report.ready ? out : [],
320
+ dictionary: inventory.dictionary,
321
+ domains: inventory.domains,
322
+ missingMedia: missing.length,
323
+ urls: inventory.urls,
324
+ }
325
+ } finally {
326
+ source.close()
327
+ }
328
+ }