@materializecms/server 0.0.0-stage → 0.1.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.
@@ -0,0 +1,352 @@
1
+ import { slugify } from '@materializecms/core';
2
+ import { htmlToPage, htmlToRichText, imagesIn, mainContent, originalOf, plain, } from './wordpress.js';
3
+ /** Which sources fit a field of this kind. */
4
+ export function sourcesFor(f) {
5
+ switch (f.kind) {
6
+ case 'text':
7
+ return f.options.multiline
8
+ ? ['excerpt', 'title', 'categories', 'tags', 'author']
9
+ : ['title', 'categories', 'tags', 'author', 'excerpt'];
10
+ case 'slug':
11
+ return ['slug'];
12
+ case 'richText':
13
+ case 'page':
14
+ return ['content'];
15
+ case 'date':
16
+ return ['date'];
17
+ case 'media':
18
+ return ['featured'];
19
+ case 'select':
20
+ return ['categories', 'tags'];
21
+ case 'relation':
22
+ return ['author'];
23
+ case 'seo':
24
+ return ['seo'];
25
+ default:
26
+ return [];
27
+ }
28
+ }
29
+ /** A first guess at where an item kind goes and which field takes what. */
30
+ export function suggest(collections, kind) {
31
+ const has = (c, k) => Object.values(c.fields).some((f) => f.kind === k);
32
+ const pool = collections.filter((c) => !c.singleton);
33
+ const target = kind === 'page'
34
+ ? (pool.find((c) => has(c, 'page')) ?? pool.find((c) => /page|pagin/.test(c.name)))
35
+ : (pool.find((c) => /post|blog|news|artic|diario|journal/.test(c.name) && has(c, 'richText')) ??
36
+ pool.find((c) => has(c, 'richText') && has(c, 'date')));
37
+ if (!target)
38
+ return null;
39
+ const fields = {};
40
+ const taken = new Set();
41
+ const pick = (name, source) => {
42
+ if (taken.has(source) && source !== 'categories')
43
+ return;
44
+ fields[name] = source;
45
+ taken.add(source);
46
+ };
47
+ for (const [name, f] of Object.entries(target.fields)) {
48
+ if (name === target.titleField)
49
+ pick(name, 'title');
50
+ else if (f.kind === 'slug')
51
+ pick(name, 'slug');
52
+ else if (f.kind === 'page' || (f.kind === 'richText' && !taken.has('content')))
53
+ pick(name, 'content');
54
+ else if (f.kind === 'text' && f.options.multiline && /excerpt|summary|somm|riass|intro|desc/i.test(name))
55
+ pick(name, 'excerpt');
56
+ else if (f.kind === 'date')
57
+ pick(name, 'date');
58
+ else if (f.kind === 'media' && !taken.has('featured'))
59
+ pick(name, 'featured');
60
+ else if ((f.kind === 'select' || f.kind === 'text') && /categ/i.test(name))
61
+ pick(name, 'categories');
62
+ else if (f.kind === 'text' && /tag|etichett/i.test(name))
63
+ pick(name, 'tags');
64
+ else if ((f.kind === 'relation' || f.kind === 'text') && /author|autor/i.test(name))
65
+ pick(name, 'author');
66
+ else if (f.kind === 'seo')
67
+ pick(name, 'seo');
68
+ }
69
+ // A multi-line text not taken yet gets the excerpt.
70
+ if (!taken.has('excerpt'))
71
+ for (const [name, f] of Object.entries(target.fields))
72
+ if (!fields[name] && f.kind === 'text' && f.options.multiline) {
73
+ pick(name, 'excerpt');
74
+ break;
75
+ }
76
+ return { collection: target.name, fields };
77
+ }
78
+ const fold = (s) => s.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
79
+ /** The options of a choice field, as value and label. */
80
+ export function optionsOf(f) {
81
+ const raw = (f.options.options ?? []);
82
+ return raw.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
83
+ }
84
+ /** A WordPress category to an option of a choice field, by value or label; null if none fits. */
85
+ export function matchOption(name, options) {
86
+ const n = fold(name);
87
+ return options.find((o) => fold(o.value) === n || fold(o.label) === n)?.value ?? null;
88
+ }
89
+ /** "Il titolo • Yoast" → "Il titolo": the site's name is added by Materialize itself. */
90
+ function seoTitle(title, site) {
91
+ if (!title)
92
+ return undefined;
93
+ const clean = plain(title);
94
+ const cut = clean.replace(new RegExp(`\\s*[|•\\-–—·:]\\s*${site.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'i'), '');
95
+ return cut || clean;
96
+ }
97
+ const pathOf = (link, base) => {
98
+ try {
99
+ const u = new URL(link, base);
100
+ return decodeURI(u.pathname).replace(/\/+$/, '') || '/';
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ };
106
+ export async function runImport(site, plan, deps) {
107
+ const progress = {
108
+ phase: 'media',
109
+ done: 0,
110
+ total: 0,
111
+ created: [],
112
+ skipped: [],
113
+ problems: [],
114
+ media: { downloaded: 0, failed: 0, reasons: {} },
115
+ redirects: 0,
116
+ };
117
+ const report = () => deps.onProgress({ ...progress });
118
+ const targetOf = (item) => (item.kind === 'post' ? plan.posts : plan.pages);
119
+ const defOf = (t) => deps.collections.find((c) => c.name === t.collection);
120
+ const items = site.items.filter((i) => {
121
+ const t = targetOf(i);
122
+ return t && defOf(t);
123
+ });
124
+ // 0. Items the API gave empty (some themes and page builders keep the page elsewhere): their
125
+ // published page is read instead, between its header and its footer.
126
+ for (const item of items)
127
+ if (plain(item.html).length < 40 && item.status === 'publish' && item.link) {
128
+ const page = await deps.fetchPage(item.link);
129
+ const main = page ? mainContent(page) : '';
130
+ if (plain(main).length > plain(item.html).length)
131
+ item.html = main;
132
+ }
133
+ // 1. Images: each address once, the original size when WordPress has it.
134
+ const wanted = new Map();
135
+ for (const item of items) {
136
+ const t = targetOf(item);
137
+ const fields = Object.values(t.fields);
138
+ if (fields.includes('content'))
139
+ for (const src of imagesIn(item.html))
140
+ wanted.set(new URL(src, site.url).href, '');
141
+ if (fields.includes('featured') && item.featured?.url)
142
+ wanted.set(new URL(item.featured.url, site.url).href, item.featured.alt);
143
+ }
144
+ const images = new Map();
145
+ progress.total = wanted.size;
146
+ report();
147
+ const queue = [...wanted];
148
+ const worker = async () => {
149
+ for (let next = queue.shift(); next; next = queue.shift()) {
150
+ const [url, alt] = next;
151
+ const original = originalOf(url);
152
+ let saved = original !== url ? await deps.saveImage(original, alt) : null;
153
+ if (!saved || 'error' in saved)
154
+ saved = await deps.saveImage(url, alt);
155
+ if ('error' in saved) {
156
+ progress.media.failed++;
157
+ progress.media.reasons[saved.error] = (progress.media.reasons[saved.error] ?? 0) + 1;
158
+ }
159
+ else {
160
+ images.set(url, saved);
161
+ progress.media.downloaded++;
162
+ }
163
+ progress.done++;
164
+ report();
165
+ }
166
+ };
167
+ await Promise.all([worker(), worker(), worker()]);
168
+ // 2. Where every item will live, so links between them point to the new pages.
169
+ const plans = items.map((item) => {
170
+ const t = targetOf(item);
171
+ const def = defOf(t);
172
+ const slug = slugify(decodeURIComponent(item.slug)) || slugify(item.title) || item.sourceId;
173
+ const sample = {};
174
+ for (const [name, source] of Object.entries(t.fields))
175
+ if (source === 'slug')
176
+ sample[name] = slug;
177
+ return { item, t, def, slug, url: deps.urlOf(def, sample) };
178
+ });
179
+ const moved = new Map();
180
+ for (const p of plans) {
181
+ const from = pathOf(p.item.link, site.url);
182
+ if (from && p.url)
183
+ moved.set(from, p.url);
184
+ }
185
+ const host = (() => {
186
+ try {
187
+ return new URL(site.url).host;
188
+ }
189
+ catch {
190
+ return '';
191
+ }
192
+ })();
193
+ const ctx = {
194
+ image: (src) => images.get(new URL(src, site.url).href),
195
+ link: (href) => {
196
+ try {
197
+ const u = new URL(href, site.url);
198
+ if (u.host !== host)
199
+ return href;
200
+ const image = images.get(u.href) ?? images.get(originalOf(u.href));
201
+ if (image)
202
+ return image.src;
203
+ const to = moved.get(decodeURI(u.pathname).replace(/\/+$/, '') || '/');
204
+ return to ? to + u.hash : href;
205
+ }
206
+ catch {
207
+ return href;
208
+ }
209
+ },
210
+ };
211
+ // 3. The documents.
212
+ progress.phase = 'content';
213
+ progress.done = 0;
214
+ progress.total = plans.length;
215
+ report();
216
+ const relations = new Map();
217
+ const moves = [];
218
+ // Addresses taken by this import: WordPress allows the same slug under different parent pages.
219
+ const taken = new Map();
220
+ for (const entry of plans) {
221
+ const { item, t, def } = entry;
222
+ let { slug, url } = entry;
223
+ try {
224
+ const mine = taken.get(def.name) ?? new Set();
225
+ taken.set(def.name, mine);
226
+ if (mine.has(slug)) {
227
+ let n = 2;
228
+ while (mine.has(`${slug}-${n}`) || (await deps.exists(def.name, `${slug}-${n}`)))
229
+ n++;
230
+ const before = slug;
231
+ slug = `${slug}-${n}`;
232
+ const sample = {};
233
+ for (const [name, source] of Object.entries(t.fields))
234
+ if (source === 'slug')
235
+ sample[name] = slug;
236
+ url = deps.urlOf(def, sample);
237
+ progress.problems.push({
238
+ title: item.title,
239
+ reason: `indirizzo già usato da un’altra pagina importata: ora è ${url ?? slug}, era ${before}`,
240
+ });
241
+ }
242
+ else if (await deps.exists(def.name, slug)) {
243
+ // Already there from before (a second import): left as it is.
244
+ progress.skipped.push({ title: item.title, reason: 'c’è già un contenuto con lo stesso indirizzo' });
245
+ continue;
246
+ }
247
+ mine.add(slug);
248
+ const data = {};
249
+ for (const [name, source] of Object.entries(t.fields)) {
250
+ const f = def.fields[name];
251
+ if (!f)
252
+ continue;
253
+ const value = await valueFor(f, source, item, ctx, plan, site, relations, deps);
254
+ if (value !== undefined)
255
+ data[name] = value;
256
+ }
257
+ for (const [name, source] of Object.entries(t.fields))
258
+ if (source === 'slug')
259
+ data[name] = slug;
260
+ const doc = await deps.create(def, data);
261
+ let published = false;
262
+ if (plan.publish && item.status === 'publish')
263
+ try {
264
+ await deps.publish(doc.id);
265
+ published = true;
266
+ }
267
+ catch {
268
+ progress.problems.push({ title: doc.title, reason: 'importato come bozza: ha campi da completare' });
269
+ }
270
+ progress.created.push({ id: doc.id, collection: def.name, title: doc.title, published });
271
+ const from = pathOf(item.link, site.url);
272
+ if (published && from && url && from !== url)
273
+ moves.push({ from, to: url });
274
+ }
275
+ catch (error) {
276
+ progress.problems.push({ title: item.title, reason: error.message });
277
+ }
278
+ finally {
279
+ progress.done++;
280
+ report();
281
+ }
282
+ }
283
+ if (plan.redirects && moves.length)
284
+ progress.redirects = await deps.addRedirects(moves);
285
+ progress.phase = 'done';
286
+ report();
287
+ return progress;
288
+ }
289
+ async function valueFor(f, source, item, ctx, plan, site, relations, deps) {
290
+ const max = typeof f.options.max === 'number' ? f.options.max : undefined;
291
+ const clip = (s) => (max && s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s);
292
+ switch (source) {
293
+ case 'title':
294
+ return clip(item.title || 'Senza titolo');
295
+ case 'slug':
296
+ return undefined;
297
+ case 'excerpt':
298
+ return item.excerpt ? clip(item.excerpt.replace(/\s*\[(…|&hellip;|\.\.\.)\]\s*$/, '…')) : undefined;
299
+ case 'date':
300
+ return item.date.slice(0, 10);
301
+ case 'content':
302
+ if (f.kind === 'page')
303
+ return htmlToPage(item.html, item.title, ctx);
304
+ if (f.kind === 'richText')
305
+ return htmlToRichText(item.html, ctx);
306
+ return clip(plain(item.html));
307
+ case 'featured':
308
+ return item.featured ? ctx.image(item.featured.url) : undefined;
309
+ case 'categories':
310
+ case 'tags': {
311
+ const names = source === 'categories' ? item.categories : item.tags;
312
+ if (f.kind === 'select') {
313
+ const options = optionsOf(f);
314
+ for (const name of names) {
315
+ const chosen = plan.categories?.[name] ?? matchOption(name, options);
316
+ if (chosen)
317
+ return chosen;
318
+ }
319
+ return plan.fallbackCategory || (f.required ? options[0]?.value : undefined);
320
+ }
321
+ return names.length ? clip(names.join(', ')) : undefined;
322
+ }
323
+ case 'author': {
324
+ if (!item.author)
325
+ return undefined;
326
+ if (f.kind === 'relation') {
327
+ const to = String(f.options.to);
328
+ if (!relations.has(to))
329
+ relations.set(to, await deps.titles(to));
330
+ return relations.get(to)?.get(fold(item.author));
331
+ }
332
+ return clip(item.author);
333
+ }
334
+ case 'seo': {
335
+ if (!item.seo)
336
+ return undefined;
337
+ // Search engines show about 60 and 160 characters: longer ones are cut on a word.
338
+ const cut = (s, n) => {
339
+ if (!s)
340
+ return undefined;
341
+ if (s.length <= n)
342
+ return s;
343
+ const head = s.slice(0, n - 1);
344
+ return `${head.slice(0, Math.max(head.lastIndexOf(' '), n - 20)).trimEnd()}…`;
345
+ };
346
+ const title = cut(seoTitle(item.seo.title, site.name), 70);
347
+ const description = cut(item.seo.description ? plain(item.seo.description) : undefined, 170);
348
+ return { ...(title ? { title } : {}), ...(description ? { description } : {}) };
349
+ }
350
+ }
351
+ }
352
+ export { fold };