@materializecms/server 0.0.0-stage → 0.2.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,562 @@
1
+ import { newId } from '@materializecms/core/page';
2
+ import { DomUtils, parseDocument } from 'htmlparser2';
3
+ export class WordPressError extends Error {
4
+ }
5
+ const parse = (html, xml = false) => parseDocument(html, xml ? { xmlMode: true } : { decodeEntities: true });
6
+ function textOf(node) {
7
+ if (node.type === 'text')
8
+ return node.data ?? '';
9
+ if (node.name === 'script' || node.name === 'style')
10
+ return '';
11
+ // CDATA sections (the export file) hold their text as a child.
12
+ return (node.children ?? []).map(textOf).join('');
13
+ }
14
+ /** HTML or entities to plain text: "Ciao & benvenuti" → "Ciao & benvenuti". */
15
+ export const plain = (html) => textOf(parse(html)).replace(/\s+/g, ' ').trim();
16
+ const STATUSES = new Set(['publish', 'draft', 'private', 'pending', 'future']);
17
+ // ---------- REST API ----------
18
+ /**
19
+ * Reads a site through its REST API. `credentials` (user and application password, from the
20
+ * WordPress profile) also brings drafts and private items. `limit` stops early, to try.
21
+ */
22
+ export async function readRest(base, fetcher, options = {}) {
23
+ const root = base.replace(/\/+$/, '');
24
+ // Pretty permalinks answer at /wp-json; plain ones only at ?rest_route=.
25
+ const endpoints = [
26
+ (route, query = '') => `${root}/wp-json${route}${query ? `?${query}` : ''}`,
27
+ (route, query = '') => `${root}/?rest_route=${route}${query ? `&${query}` : ''}`,
28
+ ];
29
+ let api = null;
30
+ let info = {};
31
+ for (const endpoint of endpoints) {
32
+ const res = await fetcher(endpoint('/')).catch(() => null);
33
+ if (res?.status === 200) {
34
+ try {
35
+ info = JSON.parse(res.text);
36
+ api = endpoint;
37
+ break;
38
+ }
39
+ catch { }
40
+ }
41
+ }
42
+ if (!api)
43
+ throw new WordPressError('Non trovo l’API di WordPress a questo indirizzo: controlla che sia il sito giusto, oppure usa il file di esportazione.');
44
+ const endpoint = api;
45
+ const items = [];
46
+ for (const kind of ['post', 'page']) {
47
+ const route = kind === 'post' ? '/wp/v2/posts' : '/wp/v2/pages';
48
+ for (let page = 1;; page++) {
49
+ // A trial needs only the first few: smaller pages answer much sooner.
50
+ const size = Math.min(100, options.limit ?? 100);
51
+ const base = `per_page=${size}&page=${page}&_embed=1&orderby=date&order=desc`;
52
+ let res = await fetcher(endpoint(route, `${base}&status=publish,future,draft,pending,private`));
53
+ // Without the right to see drafts, WordPress refuses the status filter: published ones only.
54
+ if (res.status === 400 || res.status === 401 || res.status === 403)
55
+ res = await fetcher(endpoint(route, base));
56
+ if (res.status === 400 && page > 1)
57
+ break;
58
+ if (res.status === 401 || res.status === 403)
59
+ throw new WordPressError('WordPress non dà accesso ai contenuti: controlla utente e password applicativa.');
60
+ if (res.status !== 200)
61
+ throw new WordPressError(`WordPress ha risposto ${res.status} leggendo ${route}`);
62
+ const list = JSON.parse(res.text);
63
+ for (const p of list)
64
+ items.push(fromRest(p, kind));
65
+ const pages = Number(res.headers.get('x-wp-totalpages') ?? 1);
66
+ if (options.limit && items.filter((i) => i.kind === kind).length >= options.limit)
67
+ break;
68
+ if (page >= pages || !list.length)
69
+ break;
70
+ }
71
+ }
72
+ const limited = options.limit
73
+ ? [
74
+ ...items.filter((i) => i.kind === 'post').slice(0, options.limit),
75
+ ...items.filter((i) => i.kind === 'page').slice(0, options.limit),
76
+ ]
77
+ : items;
78
+ return { name: plain(info.name ?? '') || root, url: info.home ?? info.url ?? root, items: limited };
79
+ }
80
+ function fromRest(p, kind) {
81
+ const terms = (p._embedded?.['wp:term'] ?? []).flat();
82
+ const media = p._embedded?.['wp:featuredmedia']?.[0];
83
+ const yoast = p.yoast_head_json;
84
+ return {
85
+ sourceId: String(p.id),
86
+ kind,
87
+ title: plain(p.title?.rendered ?? ''),
88
+ slug: p.slug,
89
+ status: (STATUSES.has(p.status) ? p.status : 'draft'),
90
+ date: isoDate(p.date_gmt ? `${p.date_gmt}Z` : (p.date ?? '')),
91
+ link: p.link,
92
+ html: p.content?.rendered ?? '',
93
+ excerpt: plain(p.excerpt?.rendered ?? ''),
94
+ featured: media?.source_url ? { url: media.source_url, alt: media.alt_text ?? '' } : undefined,
95
+ categories: terms.filter((t) => t.taxonomy === 'category' && t.name).map((t) => plain(t.name)),
96
+ tags: terms.filter((t) => t.taxonomy === 'post_tag' && t.name).map((t) => plain(t.name)),
97
+ author: p._embedded?.author?.[0]?.name,
98
+ seo: yoast ? { title: yoast.title, description: yoast.description ?? yoast.og_description } : undefined,
99
+ };
100
+ }
101
+ function isoDate(value) {
102
+ const d = new Date(value);
103
+ return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
104
+ }
105
+ // ---------- export file (WXR) ----------
106
+ /** Reads the file of Strumenti → Esporta → Tutti i contenuti. */
107
+ export function readWxr(xml) {
108
+ const doc = parse(xml, true);
109
+ const find = (node, name) => {
110
+ for (const c of node.children ?? []) {
111
+ if (c.name === name)
112
+ return c;
113
+ const deep = find(c, name);
114
+ if (deep)
115
+ return deep;
116
+ }
117
+ return undefined;
118
+ };
119
+ const channel = find(doc, 'channel');
120
+ if (!channel || !find(channel, 'wp:wxr_version'))
121
+ throw new WordPressError('Questo non è un file di esportazione di WordPress (Strumenti → Esporta).');
122
+ const child = (node, name) => (node.children ?? []).find((c) => c.name === name);
123
+ const text = (node) => (node ? textOf(node).trim() : '');
124
+ const rawItems = (channel.children ?? []).filter((c) => c.name === 'item');
125
+ const attachments = new Map();
126
+ const meta = (item) => {
127
+ const out = {};
128
+ for (const m of (item.children ?? []).filter((c) => c.name === 'wp:postmeta'))
129
+ out[text(child(m, 'wp:meta_key'))] = text(child(m, 'wp:meta_value'));
130
+ return out;
131
+ };
132
+ for (const item of rawItems)
133
+ if (text(child(item, 'wp:post_type')) === 'attachment')
134
+ attachments.set(text(child(item, 'wp:post_id')), {
135
+ url: text(child(item, 'wp:attachment_url')),
136
+ alt: meta(item)._wp_attachment_image_alt ?? '',
137
+ });
138
+ const items = [];
139
+ for (const item of rawItems) {
140
+ const type = text(child(item, 'wp:post_type'));
141
+ if (type !== 'post' && type !== 'page')
142
+ continue;
143
+ const status = text(child(item, 'wp:status'));
144
+ if (status === 'trash' || status === 'auto-draft' || status === 'inherit')
145
+ continue;
146
+ const m = meta(item);
147
+ const terms = (item.children ?? []).filter((c) => c.name === 'category');
148
+ const date = text(child(item, 'wp:post_date_gmt'));
149
+ items.push({
150
+ sourceId: text(child(item, 'wp:post_id')),
151
+ kind: type,
152
+ title: plain(text(child(item, 'title'))),
153
+ slug: text(child(item, 'wp:post_name')) || text(child(item, 'wp:post_id')),
154
+ status: (STATUSES.has(status) ? status : 'draft'),
155
+ date: isoDate(date && !date.startsWith('0000') ? `${date.replace(' ', 'T')}Z` : text(child(item, 'wp:post_date'))),
156
+ link: text(child(item, 'link')),
157
+ html: withoutShortcodes(text(child(item, 'content:encoded'))),
158
+ excerpt: plain(text(child(item, 'excerpt:encoded'))),
159
+ featured: m._thumbnail_id ? attachments.get(m._thumbnail_id) : undefined,
160
+ categories: terms.filter((t) => t.attribs?.domain === 'category').map((t) => plain(text(t))),
161
+ tags: terms.filter((t) => t.attribs?.domain === 'post_tag').map((t) => plain(text(t))),
162
+ author: text(child(item, 'dc:creator')) || undefined,
163
+ seo: m._yoast_wpseo_title || m._yoast_wpseo_metadesc || m.rank_math_title || m.rank_math_description
164
+ ? {
165
+ title: m._yoast_wpseo_title || m.rank_math_title || undefined,
166
+ description: m._yoast_wpseo_metadesc || m.rank_math_description || undefined,
167
+ }
168
+ : undefined,
169
+ });
170
+ }
171
+ return { name: plain(text(child(channel, 'title'))), url: text(child(channel, 'link')), items };
172
+ }
173
+ /**
174
+ * Stored content of page builders is made of shortcodes ([vc_row], [et_pb_text]…): the marks go,
175
+ * the words and images inside them stay. Unknown wrappers too: they show nothing on their own.
176
+ */
177
+ export function withoutShortcodes(html) {
178
+ return html
179
+ .replace(/<!--\s*\/?wp:[\s\S]*?-->/g, '')
180
+ .replace(/\[\/?[a-z][\w-]*(?:\s[^\]]*)?\]/gi, '')
181
+ .replace(/\n{3,}/g, '\n\n')
182
+ .split(/\n\s*\n/)
183
+ .map((block) => (/^\s*</.test(block) ? block : `<p>${block.trim().replace(/\n/g, '<br>')}</p>`))
184
+ .join('\n');
185
+ }
186
+ // ---------- a published page, when the API gives no content ----------
187
+ /**
188
+ * The main content of a whole published page, as HTML: what a theme or a page builder shows
189
+ * between its header and its footer. Used when WordPress keeps the page outside its content
190
+ * (some themes, some page builders), so the API answers empty.
191
+ */
192
+ export function mainContent(html) {
193
+ const doc = parse(html);
194
+ const all = [];
195
+ const walk = (n) => {
196
+ all.push(n);
197
+ for (const c of n.children ?? [])
198
+ walk(c);
199
+ };
200
+ walk(doc);
201
+ const cls = (n) => ` ${n.attribs?.class ?? ''} `;
202
+ const attr = (n, k) => n.attribs?.[k] ?? '';
203
+ const pick =
204
+ // Elementor, Divi, then the usual theme markup.
205
+ all.find((n) => /^wp-(page|post)$|^single-post$/.test(attr(n, 'data-elementor-type'))) ??
206
+ all.find((n) => / et-l--post | et_builder_inner_content /.test(cls(n))) ??
207
+ all.find((n) => n.name === 'main' || attr(n, 'role') === 'main') ??
208
+ all.find((n) => n.name === 'article') ??
209
+ all.find((n) => / entry-content | post-content | page-content /.test(cls(n))) ??
210
+ all.find((n) => n.name === 'body') ??
211
+ doc;
212
+ // What surrounds the content on every page goes: menus, headers, footers, sidebars, forms.
213
+ const drop = (n) => ['header', 'footer', 'nav', 'aside', 'script', 'style', 'noscript', 'form', 'template'].includes(n.name ?? '') ||
214
+ /^(header|footer|popup)$/.test(attr(n, 'data-elementor-type')) ||
215
+ / elementor-location-(header|footer|popup) | site-header | site-footer | screen-reader-text /.test(cls(n));
216
+ const clean = (n) => {
217
+ n.children = (n.children ?? []).filter((c) => !drop(c));
218
+ for (const c of n.children)
219
+ clean(c);
220
+ };
221
+ clean(pick);
222
+ return DomUtils.getOuterHTML(pick);
223
+ }
224
+ /** Every image address in some HTML, the original size first (WordPress adds "-1024x768"). */
225
+ export function imagesIn(html) {
226
+ const out = [];
227
+ const walk = (n) => {
228
+ if (n.name === 'img') {
229
+ const src = imageSrc(n);
230
+ if (src)
231
+ out.push(src);
232
+ }
233
+ for (const c of n.children ?? [])
234
+ walk(c);
235
+ };
236
+ walk(parse(html));
237
+ return [...new Set(out)];
238
+ }
239
+ function imageSrc(img) {
240
+ const a = img.attribs ?? {};
241
+ // Lazy-loading plugins keep the real address aside.
242
+ const src = a['data-src'] || a['data-lazy-src'] || a.src;
243
+ if (!src || src.startsWith('data:'))
244
+ return undefined;
245
+ return src;
246
+ }
247
+ /** The full-size original of a resized WordPress image: "foto-1024x768.jpg" → "foto.jpg". */
248
+ export const originalOf = (url) => url.replace(/-\d{2,5}x\d{2,5}(?=\.[a-z0-9]+(?:[?#].*)?$)/i, '');
249
+ const BLOCKS = new Set([
250
+ 'p',
251
+ 'h1',
252
+ 'h2',
253
+ 'h3',
254
+ 'h4',
255
+ 'h5',
256
+ 'h6',
257
+ 'ul',
258
+ 'ol',
259
+ 'li',
260
+ 'blockquote',
261
+ 'figure',
262
+ 'img',
263
+ 'div',
264
+ 'section',
265
+ 'article',
266
+ 'header',
267
+ 'footer',
268
+ 'main',
269
+ 'aside',
270
+ 'table',
271
+ 'tr',
272
+ 'td',
273
+ 'th',
274
+ 'tbody',
275
+ 'thead',
276
+ 'pre',
277
+ 'figcaption',
278
+ 'hr',
279
+ 'iframe',
280
+ 'video',
281
+ 'form',
282
+ 'nav',
283
+ ]);
284
+ const SKIP = new Set(['script', 'style', 'noscript', 'form', 'svg', 'button', 'input', 'select', 'textarea', 'nav']);
285
+ function runsOf(nodes, ctx, mark = {}) {
286
+ const runs = [];
287
+ const walk = (n, m) => {
288
+ if (n.type === 'text') {
289
+ const t = (n.data ?? '').replace(/\s+/g, ' ');
290
+ if (t)
291
+ runs.push({ text: t, ...m });
292
+ return;
293
+ }
294
+ if (n.type !== 'tag' || SKIP.has(n.name ?? ''))
295
+ return;
296
+ if (n.name === 'br') {
297
+ runs.push({ text: '\n', ...m });
298
+ return;
299
+ }
300
+ if (n.name === 'img')
301
+ return;
302
+ const next = { ...m };
303
+ if (n.name === 'strong' || n.name === 'b')
304
+ next.b = true;
305
+ if (n.name === 'em' || n.name === 'i')
306
+ next.i = true;
307
+ if (n.name === 'a' && n.attribs?.href)
308
+ next.href = ctx.link(n.attribs.href);
309
+ for (const c of n.children ?? [])
310
+ walk(c, next);
311
+ };
312
+ for (const n of nodes)
313
+ walk(n, mark);
314
+ // Same marks side by side join; spaces at the edges go.
315
+ const merged = [];
316
+ for (const r of runs) {
317
+ const last = merged[merged.length - 1];
318
+ if (last && last.b === r.b && last.i === r.i && last.href === r.href)
319
+ last.text += r.text;
320
+ else
321
+ merged.push({ ...r });
322
+ }
323
+ if (merged[0])
324
+ merged[0].text = merged[0].text.replace(/^\s+/, '');
325
+ const end = merged[merged.length - 1];
326
+ if (end)
327
+ end.text = end.text.replace(/\s+$/, '');
328
+ return merged.filter((r) => r.text);
329
+ }
330
+ /** The content of some HTML in reading order: titles, paragraphs, list items, quotes, images, buttons. */
331
+ function pieces(html, ctx) {
332
+ const out = [];
333
+ const inlineBuffer = [];
334
+ const flush = () => {
335
+ if (!inlineBuffer.length)
336
+ return;
337
+ const runs = runsOf(inlineBuffer.splice(0), ctx);
338
+ if (runs.length)
339
+ out.push({ kind: 'text', style: 'p', runs });
340
+ };
341
+ const walk = (n, quote = false) => {
342
+ if (n.type === 'text') {
343
+ if ((n.data ?? '').trim())
344
+ inlineBuffer.push(n);
345
+ return;
346
+ }
347
+ if (n.type !== 'tag')
348
+ return;
349
+ const name = n.name ?? '';
350
+ if (SKIP.has(name))
351
+ return;
352
+ if (!BLOCKS.has(name)) {
353
+ // A lone image inside a link or a span still counts as an image.
354
+ if (name === 'a' && isButton(n)) {
355
+ flush();
356
+ const text = textOf(n).replace(/\s+/g, ' ').trim();
357
+ if (text)
358
+ out.push({ kind: 'button', text, href: ctx.link(n.attribs?.href ?? '#') });
359
+ return;
360
+ }
361
+ const img = findImg(n);
362
+ if (img && !textOf(n).trim()) {
363
+ flush();
364
+ const src = imageSrc(img);
365
+ if (src)
366
+ out.push({ kind: 'image', src });
367
+ return;
368
+ }
369
+ inlineBuffer.push(n);
370
+ return;
371
+ }
372
+ flush();
373
+ if (name === 'img') {
374
+ const src = imageSrc(n);
375
+ if (src)
376
+ out.push({ kind: 'image', src });
377
+ return;
378
+ }
379
+ if (/^h[1-6]$/.test(name)) {
380
+ const runs = runsOf(n.children ?? [], ctx);
381
+ const level = Math.min(4, Number(name[1]));
382
+ if (runs.length)
383
+ out.push({ kind: 'text', style: `h${level}`, runs });
384
+ return;
385
+ }
386
+ if (name === 'p' || name === 'figcaption' || name === 'pre') {
387
+ // Images inside a paragraph come out on their own.
388
+ for (const img of allImgs(n)) {
389
+ const src = imageSrc(img);
390
+ if (src)
391
+ out.push({ kind: 'image', src });
392
+ }
393
+ const runs = runsOf(n.children ?? [], ctx);
394
+ if (runs.length)
395
+ out.push({ kind: 'text', style: quote ? 'quote' : name === 'figcaption' ? 'caption' : 'p', runs });
396
+ return;
397
+ }
398
+ if (name === 'li') {
399
+ const runs = runsOf((n.children ?? []).filter((c) => c.name !== 'ul' && c.name !== 'ol'), ctx);
400
+ if (runs.length)
401
+ out.push({ kind: 'text', style: 'li', runs });
402
+ for (const c of n.children ?? [])
403
+ if (c.name === 'ul' || c.name === 'ol')
404
+ walk(c);
405
+ return;
406
+ }
407
+ if (name === 'blockquote') {
408
+ const before = out.length;
409
+ for (const c of n.children ?? [])
410
+ walk(c, true);
411
+ flush();
412
+ // A quote written without paragraphs inside.
413
+ if (out.length === before) {
414
+ const runs = runsOf(n.children ?? [], ctx);
415
+ if (runs.length)
416
+ out.push({ kind: 'text', style: 'quote', runs });
417
+ }
418
+ return;
419
+ }
420
+ if (name === 'iframe' || name === 'video') {
421
+ const src = n.attribs?.src;
422
+ // Videos and embeds stay a link to the original, to put back by hand.
423
+ if (src)
424
+ out.push({ kind: 'text', style: 'p', runs: [{ text: 'Video: ' }, { text: src, href: src }] });
425
+ return;
426
+ }
427
+ if (name === 'hr')
428
+ return;
429
+ for (const c of n.children ?? [])
430
+ walk(c, quote);
431
+ flush();
432
+ };
433
+ for (const c of parse(html).children ?? [])
434
+ walk(c);
435
+ flush();
436
+ return out;
437
+ }
438
+ const isButton = (a) => /(^|\s)(wp-block-button__link|elementor-button|et_pb_button|vc_btn|button|btn)(\s|$|-)/.test(a.attribs?.class ?? '');
439
+ const findImg = (n) => n.name === 'img' ? n : (n.children ?? []).map(findImg).find(Boolean);
440
+ const allImgs = (n) => (n.name === 'img' ? [n] : (n.children ?? []).flatMap(allImgs));
441
+ const toRichRuns = (runs) => runs.map((r) => ({
442
+ text: r.text,
443
+ ...(r.b ? { bold: true } : {}),
444
+ ...(r.i ? { italic: true } : {}),
445
+ ...(r.href ? { href: r.href } : {}),
446
+ }));
447
+ /** HTML to Materialize rich text: paragraphs, titles, lists, quotes and images (from the library). */
448
+ export function htmlToRichText(html, ctx) {
449
+ const out = [];
450
+ for (const p of pieces(html, ctx)) {
451
+ if (p.kind === 'image') {
452
+ const img = ctx.image(p.src);
453
+ if (img)
454
+ out.push({ type: 'image', children: [], image: img });
455
+ }
456
+ else if (p.kind === 'button') {
457
+ out.push({ type: 'paragraph', children: [{ text: p.text, bold: true, href: p.href }] });
458
+ }
459
+ else if (p.style === 'h1' || p.style === 'h2')
460
+ out.push({ type: 'heading', level: 2, children: toRichRuns(p.runs) });
461
+ else if (p.style === 'h3')
462
+ out.push({ type: 'heading', level: 3, children: toRichRuns(p.runs) });
463
+ else if (p.style === 'h4')
464
+ out.push({ type: 'heading', level: 4, children: toRichRuns(p.runs) });
465
+ else if (p.style === 'quote')
466
+ out.push({ type: 'quote', children: toRichRuns(p.runs) });
467
+ else if (p.style === 'li')
468
+ out.push({ type: 'listItem', children: toRichRuns(p.runs) });
469
+ else if (p.style === 'caption')
470
+ out.push({ type: 'paragraph', children: toRichRuns(p.runs).map((r) => ({ ...r, italic: true })) });
471
+ else
472
+ out.push({ type: 'paragraph', children: toRichRuns(p.runs) });
473
+ }
474
+ return out;
475
+ }
476
+ /**
477
+ * HTML to a page on the canvas, plainly laid out: a section for each main title, with its words,
478
+ * images and buttons in order. The layout of the original is redone afterwards (by hand or with
479
+ * the assistant); here nothing of the content is lost.
480
+ */
481
+ export function htmlToPage(html, title, ctx) {
482
+ const sections = [];
483
+ let current = null;
484
+ const section = () => {
485
+ current = { id: newId(), type: 'frame', name: 'Sezione', layout: 'col', gap: 20, size: 'm', children: [] };
486
+ sections.push(current);
487
+ return current;
488
+ };
489
+ const add = (n) => (current ?? section()).children?.push(n);
490
+ const text = (runs, style, name) => {
491
+ const r = runs.map((x) => ({
492
+ t: x.text,
493
+ ...(x.b ? { b: true } : {}),
494
+ ...(x.i ? { i: true } : {}),
495
+ ...(x.href ? { href: x.href } : {}),
496
+ }));
497
+ const plainText = r.map((x) => x.t).join('');
498
+ const simple = r.length === 1 && !r[0]?.b && !r[0]?.i && !r[0]?.href;
499
+ return { id: newId(), type: 'text', name, text: plainText, ...(simple ? {} : { runs: r }), style, width: 'fill' };
500
+ };
501
+ const all = pieces(html, ctx);
502
+ // The page title opens the page, unless the content has a main title of its own.
503
+ const ownTitle = all.some((p) => p.kind === 'text' && p.style === 'h1');
504
+ if (title && !ownTitle)
505
+ add(text([{ text: title }], 'h1', 'Titolo'));
506
+ for (const p of all) {
507
+ if (p.kind === 'image') {
508
+ const img = ctx.image(p.src);
509
+ if (img)
510
+ add({
511
+ id: newId(),
512
+ type: 'image',
513
+ name: 'Immagine',
514
+ src: img.src,
515
+ alt: img.alt,
516
+ ratio: img.width && img.height ? `${img.width}/${img.height}` : '16/9',
517
+ radius: 'card',
518
+ width: 'fill',
519
+ });
520
+ continue;
521
+ }
522
+ if (p.kind === 'button') {
523
+ add({
524
+ id: newId(),
525
+ type: 'button',
526
+ name: 'Pulsante',
527
+ text: p.text,
528
+ href: p.href,
529
+ variant: 'primary',
530
+ width: 'hug',
531
+ padX: 24,
532
+ padY: 14,
533
+ radius: 'btn',
534
+ });
535
+ continue;
536
+ }
537
+ if ((p.style === 'h1' || p.style === 'h2') && current?.children?.length)
538
+ section();
539
+ const style = p.style === 'h1'
540
+ ? 'h1'
541
+ : p.style === 'h2'
542
+ ? 'h2'
543
+ : p.style === 'h3' || p.style === 'h4'
544
+ ? 'h3'
545
+ : p.style === 'caption'
546
+ ? 'small'
547
+ : 'body';
548
+ const runs = p.style === 'li'
549
+ ? [{ text: '• ' }, ...p.runs]
550
+ : p.style === 'quote'
551
+ ? p.runs.map((r) => ({ ...r, i: true }))
552
+ : p.runs;
553
+ add(text(runs, style, p.style === 'li'
554
+ ? 'Punto elenco'
555
+ : p.style.startsWith('h')
556
+ ? 'Titolo'
557
+ : p.style === 'quote'
558
+ ? 'Citazione'
559
+ : 'Testo'));
560
+ }
561
+ return sections;
562
+ }
@@ -0,0 +1,97 @@
1
+ import type { Collection, Field, Media } from '@materializecms/core';
2
+ import { type WpSite } from './wordpress.ts';
3
+ /**
4
+ * A WordPress site into Materialize: which collection each kind of item goes to, which field
5
+ * takes what, then the import itself (images into the library, links pointing to the new pages,
6
+ * SEO kept, old addresses redirected).
7
+ */
8
+ /** What of a WordPress item a field can take. */
9
+ export type WpSource = 'title' | 'slug' | 'content' | 'excerpt' | 'date' | 'featured' | 'categories' | 'tags' | 'author' | 'seo';
10
+ export interface ImportTarget {
11
+ collection: string;
12
+ /** Field name → what it takes. */
13
+ fields: Record<string, WpSource>;
14
+ }
15
+ export interface ImportPlan {
16
+ posts: ImportTarget | null;
17
+ pages: ImportTarget | null;
18
+ /** Items published on WordPress are published here too (when complete); otherwise drafts. */
19
+ publish: boolean;
20
+ /** Old addresses redirect to the new ones. */
21
+ redirects: boolean;
22
+ locale: string;
23
+ /** For a choice field: WordPress category → option value; unmatched ones take `fallback`. */
24
+ categories?: Record<string, string>;
25
+ fallbackCategory?: string;
26
+ }
27
+ /** Which sources fit a field of this kind. */
28
+ export declare function sourcesFor(f: Field): WpSource[];
29
+ /** A first guess at where an item kind goes and which field takes what. */
30
+ export declare function suggest(collections: Collection[], kind: 'post' | 'page'): ImportTarget | null;
31
+ declare const fold: (s: string) => string;
32
+ /** The options of a choice field, as value and label. */
33
+ export declare function optionsOf(f: Field): {
34
+ value: string;
35
+ label: string;
36
+ }[];
37
+ /** A WordPress category to an option of a choice field, by value or label; null if none fits. */
38
+ export declare function matchOption(name: string, options: {
39
+ value: string;
40
+ label: string;
41
+ }[]): string | null;
42
+ export interface ImportProgress {
43
+ phase: 'media' | 'content' | 'done' | 'failed';
44
+ done: number;
45
+ total: number;
46
+ created: {
47
+ id: string;
48
+ collection: string;
49
+ title: string;
50
+ published: boolean;
51
+ }[];
52
+ skipped: {
53
+ title: string;
54
+ reason: string;
55
+ }[];
56
+ problems: {
57
+ title: string;
58
+ reason: string;
59
+ }[];
60
+ media: {
61
+ downloaded: number;
62
+ failed: number;
63
+ reasons: Record<string, number>;
64
+ };
65
+ redirects: number;
66
+ error?: string;
67
+ }
68
+ export interface ImportDeps {
69
+ collections: Collection[];
70
+ /** The whole published page at an address, as HTML; null when it cannot be read. */
71
+ fetchPage: (url: string) => Promise<string | null>;
72
+ /** Downloads an image into the library; null when it cannot be had. */
73
+ saveImage: (url: string, alt: string) => Promise<Media | {
74
+ error: string;
75
+ }>;
76
+ /** An existing document with this slug, in this collection and language. */
77
+ exists: (collection: string, slug: string) => Promise<boolean>;
78
+ /** Entries of a collection by title, for relations (an author → a person of the team). */
79
+ titles: (collection: string) => Promise<Map<string, string>>;
80
+ /** Creates a draft; returns its id and address. */
81
+ create: (def: Collection, data: Record<string, unknown>) => Promise<{
82
+ id: string;
83
+ title: string;
84
+ url: string | null;
85
+ }>;
86
+ /** Publishes it; throws when fields are missing. */
87
+ publish: (id: string) => Promise<void>;
88
+ /** The address a document will have, from its data. */
89
+ urlOf: (def: Collection, data: Record<string, unknown>) => string | null;
90
+ addRedirects: (moves: {
91
+ from: string;
92
+ to: string;
93
+ }[]) => Promise<number>;
94
+ onProgress: (p: ImportProgress) => void;
95
+ }
96
+ export declare function runImport(site: WpSite, plan: ImportPlan, deps: ImportDeps): Promise<ImportProgress>;
97
+ export { fold };