@materializecms/server 0.5.0 → 0.6.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/brief.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { type PageNode, type Theme } from '@materializecms/core/page';
2
+ import type { BrandVoice } from '@materializecms/core/voice';
3
+ import { type ApiCall, type SiteTool } from './tools.ts';
4
+ /**
5
+ * A site from a brief: the AI reads a few lines about the business and builds the first pages
6
+ * with the section library. Everything it makes is a new draft: it never changes pages that
7
+ * already exist, shared sections or anything live. Colors, fonts and the brand voice are only
8
+ * proposed; the person applies them with a click.
9
+ */
10
+ /** At most this many new drafts in one run. */
11
+ export declare const BRIEF_MAX_PAGES = 8;
12
+ export interface BriefResult {
13
+ created: {
14
+ id: string;
15
+ collection: string;
16
+ title: string;
17
+ url: string | null;
18
+ }[];
19
+ /** The whole theme as it would be with the proposal applied. */
20
+ theme: Theme | null;
21
+ voice: BrandVoice | null;
22
+ }
23
+ /** What a page may not carry when the AI makes it: links to shared content that is live. */
24
+ export declare function plain(n: PageNode): PageNode;
25
+ export declare function briefTools(call: ApiCall, options: {
26
+ onCreated: (doc: BriefResult['created'][number]) => void;
27
+ askVoice: boolean;
28
+ /** What the run made so far: a reply to the AI goes on from there. */
29
+ result?: BriefResult;
30
+ }): {
31
+ tools: SiteTool[];
32
+ result: BriefResult;
33
+ };
34
+ /** The instructions for a run, in English like the other prompts; the pages are written in the site's language. */
35
+ export declare function briefSystem(input: {
36
+ site: string;
37
+ language: string;
38
+ collections: string;
39
+ existing: string;
40
+ voice: string;
41
+ askVoice: boolean;
42
+ theme: boolean;
43
+ }): string;
package/src/brief.js ADDED
@@ -0,0 +1,172 @@
1
+ import { normalizeTheme } from '@materializecms/core/page';
2
+ import { z } from 'zod';
3
+ import { contentTools, pageTools, ToolError } from './tools.js';
4
+ /**
5
+ * A site from a brief: the AI reads a few lines about the business and builds the first pages
6
+ * with the section library. Everything it makes is a new draft: it never changes pages that
7
+ * already exist, shared sections or anything live. Colors, fonts and the brand voice are only
8
+ * proposed; the person applies them with a click.
9
+ */
10
+ /** At most this many new drafts in one run. */
11
+ export const BRIEF_MAX_PAGES = 8;
12
+ /** What a page may not carry when the AI makes it: links to shared content that is live. */
13
+ export function plain(n) {
14
+ const { global: _g, component: _c, overrides: _o, ...rest } = n;
15
+ return { ...rest, children: n.children?.map(plain) };
16
+ }
17
+ const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'a hex color like #1a2b3c');
18
+ const colors = z
19
+ .object({
20
+ bg: color,
21
+ surface: color,
22
+ text: color,
23
+ muted: color,
24
+ accent: color,
25
+ onAccent: color,
26
+ inverse: color,
27
+ onInverse: color,
28
+ })
29
+ .partial();
30
+ export function briefTools(call, options) {
31
+ const result = options.result ?? { created: [], theme: null, voice: null };
32
+ let schema = null;
33
+ const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
34
+ const mine = (id) => {
35
+ if (!id || !result.created.some((d) => d.id === id))
36
+ throw new ToolError('You may change only the drafts you created in this run (create_entry); pages that already exist stay as they are.');
37
+ return id;
38
+ };
39
+ const pageFieldOf = async (collection) => (await getSchema()).collections.find((c) => c.name === collection)?.fields.find((f) => f.kind === 'page')?.name;
40
+ const base = contentTools(call, getSchema);
41
+ const pick = (name) => {
42
+ const t = base.find((x) => x.name === name);
43
+ if (!t)
44
+ throw new Error(`missing tool ${name}`);
45
+ return t;
46
+ };
47
+ const tools = [
48
+ ...['get_site', 'list_entries', 'get_entry', 'list_library', 'get_theme', 'list_media'].map(pick),
49
+ {
50
+ ...pick('create_entry'),
51
+ description: 'A new draft in a collection, with the fields given (see get_site for their names). The address (slug) follows the title when left out. For a page built on the canvas, leave the page field out and fill it afterwards with edit_page.',
52
+ run: async (input) => {
53
+ if (result.created.length >= BRIEF_MAX_PAGES)
54
+ throw new ToolError(`At most ${BRIEF_MAX_PAGES} new drafts in one run: stop here.`);
55
+ const field = await pageFieldOf(input.collection);
56
+ const data = { ...input.data };
57
+ if (field)
58
+ data[field] = Array.isArray(data[field]) ? data[field].map(plain) : [];
59
+ const doc = await call('POST', `/api/collections/${encodeURIComponent(input.collection)}`, { data, locale: input.locale });
60
+ const created = { id: doc.id, collection: doc.collection, title: doc.title, url: doc.url };
61
+ result.created.push(created);
62
+ options.onCreated(created);
63
+ return { id: doc.id, title: doc.title, url: doc.url, issues: doc.issues };
64
+ },
65
+ },
66
+ {
67
+ ...pick('update_entry'),
68
+ run: async (input) => {
69
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(mine(input.id))}`);
70
+ const field = await pageFieldOf(doc.collection);
71
+ const data = { ...doc.data, ...input.data };
72
+ if (field && Array.isArray(data[field]))
73
+ data[field] = data[field].map(plain);
74
+ const saved = await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
75
+ data,
76
+ });
77
+ const entry = result.created.find((d) => d.id === doc.id);
78
+ if (entry)
79
+ Object.assign(entry, { title: saved.title, url: saved.url });
80
+ return { id: saved.id, title: saved.title, url: saved.url, issues: saved.issues };
81
+ },
82
+ },
83
+ ...pageTools({
84
+ withId: true,
85
+ brand: async () => (await getSchema()).site.name,
86
+ source: async (id) => {
87
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(mine(id))}`);
88
+ const field = await pageFieldOf(doc.collection);
89
+ if (!field)
90
+ throw new ToolError(`"${doc.title}" is not a page built on the canvas`);
91
+ let data = doc.data;
92
+ return {
93
+ load: async () => (Array.isArray(data[field]) ? data[field] : []),
94
+ save: async (nodes) => {
95
+ data = { ...data, [field]: nodes.map(plain) };
96
+ await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, { data });
97
+ },
98
+ };
99
+ },
100
+ }),
101
+ {
102
+ name: 'propose_theme',
103
+ title: 'Propongo colori e caratteri',
104
+ description: 'Proposes the site’s colors (light and dark), fonts (Google Fonts names) and roundness. Nothing changes now: the person sees the proposal at the end and applies it if they like it. Call it once, with the whole palette; pages built meanwhile use the theme tokens, so they follow it.',
105
+ input: z.object({
106
+ display: z.string().max(60).optional().describe('Font for titles'),
107
+ body: z.string().max(60).optional().describe('Font for text'),
108
+ colors: z.object({ light: colors.optional(), dark: colors.optional() }).optional(),
109
+ btnRadius: z.number().int().min(0).max(999).optional(),
110
+ cardRadius: z.number().int().min(0).max(64).optional(),
111
+ }),
112
+ run: async (input) => {
113
+ const { theme } = await call('GET', '/api/theme');
114
+ const next = normalizeTheme({
115
+ ...theme,
116
+ ...(input.display ? { display: input.display, displayCategory: undefined } : {}),
117
+ ...(input.body ? { body: input.body, bodyCategory: undefined } : {}),
118
+ ...(input.btnRadius !== undefined ? { btnRadius: input.btnRadius } : {}),
119
+ ...(input.cardRadius !== undefined ? { cardRadius: input.cardRadius } : {}),
120
+ colors: {
121
+ light: { ...theme.colors.light, ...input.colors?.light },
122
+ dark: { ...theme.colors.dark, ...input.colors?.dark },
123
+ },
124
+ });
125
+ result.theme = next;
126
+ return 'Proposal kept: the person will choose whether to apply it.';
127
+ },
128
+ },
129
+ ];
130
+ if (options.askVoice)
131
+ tools.push({
132
+ name: 'propose_voice',
133
+ title: 'Propongo la voce del brand',
134
+ description: 'Proposes how the site speaks, from the brief: who the brand is and who it writes for, up to three tones, how readers are addressed, words to prefer and to avoid, other rules. Nothing is saved now: the person sees it at the end. Call it once.',
135
+ input: z.object({
136
+ about: z.string().max(1000).optional(),
137
+ tones: z.array(z.string().max(40)).max(3).optional(),
138
+ address: z.enum(['tu', 'voi', 'lei']).optional(),
139
+ prefer: z.string().max(500).optional(),
140
+ avoid: z.string().max(500).optional(),
141
+ rules: z.string().max(1000).optional(),
142
+ }),
143
+ run: async (input) => {
144
+ result.voice = input;
145
+ return 'Proposal kept: the person will choose whether to save it.';
146
+ },
147
+ });
148
+ return { tools, result };
149
+ }
150
+ /** The instructions for a run, in English like the other prompts; the pages are written in the site's language. */
151
+ export function briefSystem(input) {
152
+ return `${input.voice ? `${input.voice}\n\n` : ''}You are building the first version of the website "${input.site}" in Materialize, a visual CMS. The person describes the business in a brief; you make the pages as drafts they will review, change and publish themselves.
153
+
154
+ How to work:
155
+ 1. Read the site (get_site) and the section library (list_library). ${input.theme ? 'Look at the theme (get_theme) and propose colors and fonts that suit the business (propose_theme), once, before building: keep text and background contrast readable (WCAG AA) in light and dark.' : 'Keep the current theme: do not propose another one.'}${input.askVoice ? ' Propose the brand voice (propose_voice), once.' : ''}
156
+ 2. Plan a small site that fits the brief: usually a home and 2–4 more pages (at most ${BRIEF_MAX_PAGES}), only the ones the business needs.
157
+ 3. For each page: create the draft (create_entry, page field left empty, slug "home" for the home page when the address is free), then fill it with edit_page: add sections from the library (add_section) and rewrite every text for this business. Every page gets the same navigation at the top and footer at the bottom, with links to the pages you make (href "/" for the home, "/slug" for the others). Remove library placeholder texts you do not use; never leave "Lorem ipsum" or invented sample names.
158
+ 4. Fill the search engine field of each page when its collection has one (kind "seo", see get_site) with update_entry: {"title": up to 60 characters, "description": 120–160 characters}.
159
+ 5. Read each page once more (get_page) and fix what is left.
160
+
161
+ Rules:
162
+ - Write every text in ${input.language}, for real readers: concrete, short, no clichés.
163
+ - Use only facts from the brief. Where a fact is missing (prices, addresses, phone numbers, names, numbers, testimonials), write a clear placeholder in square brackets, e.g. "[Indirizzo dello studio]", so the person sees what to fill in. Never invent reviews, clients, awards or figures.
164
+ - Images: use pictures already in the media library (list_media) when they fit; otherwise keep the library's placeholder images and write a useful alt text describing the picture to add.
165
+ - Pages that already exist stay untouched; do not create a page whose address is already taken (existing pages are listed below).
166
+ - Reference pictures, when attached, show a look the person likes: take their mood, layout and colors, never their brand, logos or texts.
167
+
168
+ Collections with pages built on the canvas: ${input.collections}
169
+ Existing entries: ${input.existing}
170
+
171
+ When done, reply with a short summary in ${input.language}: the pages made and what the person should complete (the bracketed placeholders, images). No lists of technical steps.`;
172
+ }
@@ -0,0 +1,13 @@
1
+ import { type ApiCall, type SiteTool } from './tools.ts';
2
+ export interface Touched {
3
+ id: string;
4
+ collection: string;
5
+ title: string;
6
+ }
7
+ export declare function studioTools(call: ApiCall, onTouched: (doc: Touched) => void): SiteTool[];
8
+ export declare function studioSystem(input: {
9
+ site: string;
10
+ user: string;
11
+ voice: string;
12
+ limited: boolean;
13
+ }): string;
package/src/studio.js ADDED
@@ -0,0 +1,75 @@
1
+ import { collapseComponents, collapseGlobals, expandComponents, expandGlobals, } from '@materializecms/core/page';
2
+ import { contentTools, pageTools, ToolError } from './tools.js';
3
+ /**
4
+ * The request on the dashboard: the AI works on the whole site, in drafts only. It cannot
5
+ * publish, schedule, share or change what is live at once (the theme, shared sections,
6
+ * components): those stay with the person, in the panel.
7
+ */
8
+ /** Tools that act on the live site at once: left out. */
9
+ const LIVE = new Set([
10
+ 'publish_entry',
11
+ 'unpublish_entry',
12
+ 'schedule_entry',
13
+ 'share_preview',
14
+ 'update_theme',
15
+ 'publish_release',
16
+ 'schedule_release',
17
+ ]);
18
+ export function studioTools(call, onTouched) {
19
+ let schema = null;
20
+ const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
21
+ const touched = (doc) => doc.collection && onTouched({ id: doc.id, collection: doc.collection, title: doc.title });
22
+ const base = contentTools(call, getSchema)
23
+ .filter((t) => !LIVE.has(t.name))
24
+ .map((t) => ['create_entry', 'update_entry', 'translate_entry'].includes(t.name)
25
+ ? {
26
+ ...t,
27
+ run: async (input) => {
28
+ const out = (await t.run(input));
29
+ if (out?.id) {
30
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(out.id)}`);
31
+ touched(doc);
32
+ }
33
+ return out;
34
+ },
35
+ }
36
+ : t);
37
+ const page = pageTools({
38
+ withId: true,
39
+ brand: async () => (await getSchema()).site.name,
40
+ source: async (id) => {
41
+ if (!id)
42
+ throw new ToolError('Say which page: its id');
43
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(id)}`);
44
+ const def = (await getSchema()).collections.find((c) => c.name === doc.collection);
45
+ const field = def?.fields.find((f) => f.kind === 'page')?.name;
46
+ if (!field)
47
+ throw new ToolError(`"${doc.title}" is not a page built on the canvas`);
48
+ const { globals } = await call('GET', '/api/globals');
49
+ const { components } = await call('GET', '/api/components');
50
+ return {
51
+ load: async () => expandComponents(expandGlobals(Array.isArray(doc.data[field]) ? doc.data[field] : [], globals), components),
52
+ save: async (nodes) => {
53
+ const withRefs = collapseComponents(nodes, components);
54
+ const collapsed = collapseGlobals(withRefs.nodes);
55
+ const changedShared = JSON.stringify(withRefs.components) !== JSON.stringify(components) ||
56
+ Object.entries(collapsed.globals).some(([k, v]) => JSON.stringify(v) !== JSON.stringify(globals[k]));
57
+ if (changedShared)
58
+ throw new ToolError('That changes a shared section or a component, which is live on every page at once: leave it as it is and tell the person to change it in the editor. Nothing was saved.');
59
+ await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
60
+ data: { ...doc.data, [field]: collapsed.nodes },
61
+ });
62
+ touched(doc);
63
+ },
64
+ };
65
+ },
66
+ });
67
+ return [...base, ...page];
68
+ }
69
+ export function studioSystem(input) {
70
+ return `${input.voice ? `${input.voice}\n\n` : ''}You are the assistant on the dashboard of Materialize, the CMS of the website "${input.site}". ${input.user} asks you for something about the site: pages, articles, texts, translations, the search engine fields.
71
+
72
+ Work with the tools: look first (get_site, list_entries, get_entry, get_page), then change. Every change goes into drafts: you cannot publish, schedule, share or change the theme, shared sections (menus, footers) or components. When something needs those, do the rest and say what the person should do in the panel.
73
+ Change only what was asked; keep the design and the ids of existing nodes; prefer library sections for new ones. Use only facts you find on the site or in the request: where one is missing, write a placeholder in square brackets.
74
+ ${input.limited ? 'This person may change only contents (texts, links, images), not layout or design.\n' : ''}When done, reply in the person's language in two or three short sentences: what you changed and what to check before publishing. If you could not do something, say so.`;
75
+ }
package/src/tools.d.ts CHANGED
@@ -38,6 +38,7 @@ type CollectionInfo = {
38
38
  titleField?: string;
39
39
  slugField?: string;
40
40
  };
41
+ export type SiteSchema = Schema;
41
42
  type Schema = {
42
43
  site: {
43
44
  name: string;
package/src/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { buildSection, LIBRARY } from '@materializecms/core/library';
2
2
  import { cloneNode, collapseComponents, collapseGlobals, expandComponents, expandGlobals, findNode, insertNode, newId, plainText, removeNode, updateNode, } from '@materializecms/core/page';
3
+ import { voicePrompt } from '@materializecms/core/voice';
3
4
  import { z } from 'zod';
4
5
  /** An answer the model can act on: what went wrong, in words. */
5
6
  export class ToolError extends Error {
@@ -257,7 +258,14 @@ export function assistantTools(call, page, brand) {
257
258
  },
258
259
  });
259
260
  return [
260
- ...contentTools(call, getSchema).filter((t) => !['publish_entry', 'unpublish_entry', 'schedule_entry', 'share_preview'].includes(t.name)),
261
+ ...contentTools(call, getSchema).filter((t) => ![
262
+ 'publish_entry',
263
+ 'unpublish_entry',
264
+ 'schedule_entry',
265
+ 'share_preview',
266
+ 'publish_release',
267
+ 'schedule_release',
268
+ ].includes(t.name)),
261
269
  ...pageTools({ source, withId: false, brand: async () => brand }),
262
270
  ];
263
271
  }
@@ -267,13 +275,15 @@ export function contentTools(call, getSchema) {
267
275
  tool({
268
276
  name: 'get_site',
269
277
  title: 'Guardo il sito',
270
- description: 'The site: its name, languages and collections (pages, articles, projects…) with their fields. Start here.',
278
+ description: 'The site: its name, languages, collections (pages, articles, projects…) with their fields, and its brand voice: follow it in every text you write. Start here.',
271
279
  input: z.object({}),
272
280
  readOnly: true,
273
281
  run: async () => {
274
282
  const s = await getSchema();
283
+ const voice = voicePrompt((await call('GET', '/api/voice').catch(() => null))?.voice);
275
284
  return {
276
285
  site: s.site,
286
+ ...(voice ? { brandVoice: voice } : {}),
277
287
  locales: s.locales,
278
288
  collections: s.collections.map((c) => ({
279
289
  name: c.name,
@@ -400,6 +410,51 @@ export function contentTools(call, getSchema) {
400
410
  return { url: link.url, expiresAt: link.expiresAt };
401
411
  },
402
412
  }),
413
+ tool({
414
+ name: 'list_releases',
415
+ title: 'Guardo i rilasci',
416
+ description: 'Releases: groups of entries that go on (or off) the site together, all or nothing, by hand or at a set time. Each with its entries, whether it is ready and what stops it.',
417
+ input: z.object({}),
418
+ readOnly: true,
419
+ run: () => call('GET', '/api/releases'),
420
+ }),
421
+ tool({
422
+ name: 'create_release',
423
+ title: 'Creo un rilascio',
424
+ description: 'A new, empty release with a name (e.g. "Collezione inverno"). Add entries with add_to_release.',
425
+ input: z.object({ name: z.string().min(1).max(120) }),
426
+ run: (input) => call('POST', '/api/releases', input),
427
+ }),
428
+ tool({
429
+ name: 'add_to_release',
430
+ title: 'Aggiungo al rilascio',
431
+ description: 'Puts an entry in a release: its draft is published when the release goes out (action "publish"), or it is taken off the site (action "unpublish"). An entry is in one release at a time. remove: true takes it out of the release.',
432
+ input: z.object({
433
+ release: z.string().describe('The release id'),
434
+ id: z.string().describe('The entry id'),
435
+ action: z.enum(['publish', 'unpublish']).default('publish'),
436
+ remove: z.boolean().optional(),
437
+ }),
438
+ run: (input) => input.remove
439
+ ? call('DELETE', `/api/releases/${encodeURIComponent(input.release)}/items/${encodeURIComponent(input.id)}`)
440
+ : call('PUT', `/api/releases/${encodeURIComponent(input.release)}/items/${encodeURIComponent(input.id)}`, {
441
+ action: input.action,
442
+ }),
443
+ }),
444
+ tool({
445
+ name: 'publish_release',
446
+ title: 'Pubblico il rilascio',
447
+ description: 'Puts everything in a release on the public site now. All entries must be ready, or nothing goes out and the reasons come back.',
448
+ input: z.object({ release: z.string() }),
449
+ run: (input) => call('POST', `/api/releases/${encodeURIComponent(input.release)}/publish`),
450
+ }),
451
+ tool({
452
+ name: 'schedule_release',
453
+ title: 'Programmo il rilascio',
454
+ description: 'Sets when a release goes out by itself, as an ISO date with time zone, e.g. 2026-11-10T09:00:00+01:00; at null: only by hand. The drafts as they are at that moment go out.',
455
+ input: z.object({ release: z.string(), at: z.string().nullable() }),
456
+ run: (input) => call('PUT', `/api/releases/${encodeURIComponent(input.release)}/schedule`, { at: input.at }),
457
+ }),
403
458
  tool({
404
459
  name: 'list_translations',
405
460
  title: 'Guardo le lingue',
@@ -75,6 +75,8 @@ export interface ConvertContext {
75
75
  }
76
76
  /** Every image address in some HTML, the original size first (WordPress adds "-1024x768"). */
77
77
  export declare function imagesIn(html: string): string[];
78
+ /** Each image address in some HTML with its alternative text, for the library. */
79
+ export declare function imageAltsIn(html: string): Map<string, string>;
78
80
  /** The full-size original of a resized WordPress image: "foto-1024x768.jpg" → "foto.jpg". */
79
81
  export declare const originalOf: (url: string) => string;
80
82
  /** HTML to Materialize rich text: paragraphs, titles, lists, quotes and images (from the library). */
package/src/wordpress.js CHANGED
@@ -236,6 +236,23 @@ export function imagesIn(html) {
236
236
  walk(parse(html));
237
237
  return [...new Set(out)];
238
238
  }
239
+ /** The alternative text written on WordPress, trimmed. */
240
+ const altOf = (img) => (img.attribs?.alt ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
241
+ /** Each image address in some HTML with its alternative text, for the library. */
242
+ export function imageAltsIn(html) {
243
+ const out = new Map();
244
+ const walk = (n) => {
245
+ if (n.name === 'img') {
246
+ const src = imageSrc(n);
247
+ if (src && !out.get(src))
248
+ out.set(src, altOf(n));
249
+ }
250
+ for (const c of n.children ?? [])
251
+ walk(c);
252
+ };
253
+ walk(parse(html));
254
+ return out;
255
+ }
239
256
  function imageSrc(img) {
240
257
  const a = img.attribs ?? {};
241
258
  // Lazy-loading plugins keep the real address aside.
@@ -363,7 +380,7 @@ function pieces(html, ctx) {
363
380
  flush();
364
381
  const src = imageSrc(img);
365
382
  if (src)
366
- out.push({ kind: 'image', src });
383
+ out.push({ kind: 'image', src, alt: altOf(img) });
367
384
  return;
368
385
  }
369
386
  inlineBuffer.push(n);
@@ -373,7 +390,7 @@ function pieces(html, ctx) {
373
390
  if (name === 'img') {
374
391
  const src = imageSrc(n);
375
392
  if (src)
376
- out.push({ kind: 'image', src });
393
+ out.push({ kind: 'image', src, alt: altOf(n) });
377
394
  return;
378
395
  }
379
396
  if (/^h[1-6]$/.test(name)) {
@@ -388,7 +405,7 @@ function pieces(html, ctx) {
388
405
  for (const img of allImgs(n)) {
389
406
  const src = imageSrc(img);
390
407
  if (src)
391
- out.push({ kind: 'image', src });
408
+ out.push({ kind: 'image', src, alt: altOf(img) });
392
409
  }
393
410
  const runs = runsOf(n.children ?? [], ctx);
394
411
  if (runs.length)
@@ -451,7 +468,7 @@ export function htmlToRichText(html, ctx) {
451
468
  if (p.kind === 'image') {
452
469
  const img = ctx.image(p.src);
453
470
  if (img)
454
- out.push({ type: 'image', children: [], image: img });
471
+ out.push({ type: 'image', children: [], image: { ...img, alt: p.alt || img.alt } });
455
472
  }
456
473
  else if (p.kind === 'button') {
457
474
  out.push({ type: 'paragraph', children: [{ text: p.text, bold: true, href: p.href }] });
@@ -512,7 +529,7 @@ export function htmlToPage(html, title, ctx) {
512
529
  type: 'image',
513
530
  name: 'Immagine',
514
531
  src: img.src,
515
- alt: img.alt,
532
+ alt: p.alt || img.alt,
516
533
  ratio: img.width && img.height ? `${img.width}/${img.height}` : '16/9',
517
534
  radius: 'card',
518
535
  width: 'fill',
package/src/wpimport.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Collection, Field, Media } from '@materializecms/core';
2
+ import type { PageNode } from '@materializecms/core/page';
2
3
  import { type WpSite } from './wordpress.ts';
3
4
  /**
4
5
  * A WordPress site into Materialize: which collection each kind of item goes to, which field
@@ -23,6 +24,8 @@ export interface ImportPlan {
23
24
  /** For a choice field: WordPress category → option value; unmatched ones take `fallback`. */
24
25
  categories?: Record<string, string>;
25
26
  fallbackCategory?: string;
27
+ /** Pages built on the canvas are laid out again by the AI with the section library (drafts). */
28
+ aiLayout?: boolean;
26
29
  }
27
30
  /** Which sources fit a field of this kind. */
28
31
  export declare function sourcesFor(f: Field): WpSource[];
@@ -40,7 +43,7 @@ export declare function matchOption(name: string, options: {
40
43
  label: string;
41
44
  }[]): string | null;
42
45
  export interface ImportProgress {
43
- phase: 'media' | 'content' | 'done' | 'failed';
46
+ phase: 'media' | 'content' | 'layout' | 'done' | 'failed';
44
47
  done: number;
45
48
  total: number;
46
49
  created: {
@@ -63,6 +66,8 @@ export interface ImportProgress {
63
66
  reasons: Record<string, number>;
64
67
  };
65
68
  redirects: number;
69
+ /** Pages the AI laid out again. */
70
+ relaid: number;
66
71
  error?: string;
67
72
  }
68
73
  export interface ImportDeps {
@@ -91,7 +96,20 @@ export interface ImportDeps {
91
96
  from: string;
92
97
  to: string;
93
98
  }[]) => Promise<number>;
99
+ /** The AI lays a page out again in its draft; a reason when it was left as it was. */
100
+ relayout?: (doc: {
101
+ id: string;
102
+ collection: string;
103
+ title: string;
104
+ }) => Promise<null | string>;
94
105
  onProgress: (p: ImportProgress) => void;
95
106
  }
96
107
  export declare function runImport(site: WpSite, plan: ImportPlan, deps: ImportDeps): Promise<ImportProgress>;
97
108
  export { fold };
109
+ /**
110
+ * Whether a page laid out again by the AI still says what it said: no text lost, nothing made up
111
+ * (library sample texts left behind count as made up), every image still there. A reason when not.
112
+ */
113
+ export declare function relayoutProblem(before: PageNode[], after: PageNode[]): string | null;
114
+ /** Descriptions the page had for its images, put back where the new layout left them empty. */
115
+ export declare function restoreAlts(before: PageNode[], after: PageNode[]): PageNode[];