@materializecms/server 0.5.0 → 0.7.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/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[];
package/src/wpimport.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { slugify } from '@materializecms/core';
2
- import { htmlToPage, htmlToRichText, imagesIn, mainContent, originalOf, plain, } from './wordpress.js';
2
+ import { htmlToPage, htmlToRichText, imageAltsIn, mainContent, originalOf, plain, } from './wordpress.js';
3
3
  /** Which sources fit a field of this kind. */
4
4
  export function sourcesFor(f) {
5
5
  switch (f.kind) {
@@ -113,6 +113,7 @@ export async function runImport(site, plan, deps) {
113
113
  problems: [],
114
114
  media: { downloaded: 0, failed: 0, reasons: {} },
115
115
  redirects: 0,
116
+ relaid: 0,
116
117
  };
117
118
  const report = () => deps.onProgress({ ...progress });
118
119
  const targetOf = (item) => (item.kind === 'post' ? plan.posts : plan.pages);
@@ -136,8 +137,11 @@ export async function runImport(site, plan, deps) {
136
137
  const t = targetOf(item);
137
138
  const fields = Object.values(t.fields);
138
139
  if (fields.includes('content'))
139
- for (const src of imagesIn(item.html))
140
- wanted.set(new URL(src, site.url).href, '');
140
+ for (const [src, alt] of imageAltsIn(item.html)) {
141
+ const url = new URL(src, site.url).href;
142
+ if (!wanted.get(url))
143
+ wanted.set(url, alt);
144
+ }
141
145
  if (fields.includes('featured') && item.featured?.url)
142
146
  wanted.set(new URL(item.featured.url, site.url).href, item.featured.alt);
143
147
  }
@@ -217,6 +221,8 @@ export async function runImport(site, plan, deps) {
217
221
  const moves = [];
218
222
  // Addresses taken by this import: WordPress allows the same slug under different parent pages.
219
223
  const taken = new Map();
224
+ // Canvas pages filled from the content: the ones the AI may lay out again.
225
+ const canvas = [];
220
226
  for (const entry of plans) {
221
227
  const { item, t, def } = entry;
222
228
  let { slug, url } = entry;
@@ -268,6 +274,8 @@ export async function runImport(site, plan, deps) {
268
274
  progress.problems.push({ title: doc.title, reason: 'importato come bozza: ha campi da completare' });
269
275
  }
270
276
  progress.created.push({ id: doc.id, collection: def.name, title: doc.title, published });
277
+ if (Object.entries(t.fields).some(([name, source]) => source === 'content' && def.fields[name]?.kind === 'page'))
278
+ canvas.push({ id: doc.id, collection: def.name, title: doc.title });
271
279
  const from = pathOf(item.link, site.url);
272
280
  if (published && from && url && from !== url)
273
281
  moves.push({ from, to: url });
@@ -282,6 +290,31 @@ export async function runImport(site, plan, deps) {
282
290
  }
283
291
  if (plan.redirects && moves.length)
284
292
  progress.redirects = await deps.addRedirects(moves);
293
+ // 4. The AI lays the pages out again, one at a time, in their drafts: what WordPress had stays
294
+ // published until someone publishes the new layout.
295
+ if (plan.aiLayout && deps.relayout && canvas.length) {
296
+ progress.phase = 'layout';
297
+ progress.done = 0;
298
+ progress.total = canvas.length;
299
+ report();
300
+ for (const doc of canvas) {
301
+ try {
302
+ const why = await deps.relayout(doc);
303
+ if (why)
304
+ progress.problems.push({ title: doc.title, reason: `impaginazione semplice lasciata: ${why}` });
305
+ else
306
+ progress.relaid++;
307
+ }
308
+ catch (error) {
309
+ progress.problems.push({
310
+ title: doc.title,
311
+ reason: `l’AI non ha rifatto l’impaginazione: ${error.message}`,
312
+ });
313
+ }
314
+ progress.done++;
315
+ report();
316
+ }
317
+ }
285
318
  progress.phase = 'done';
286
319
  report();
287
320
  return progress;
@@ -350,3 +383,79 @@ async function valueFor(f, source, item, ctx, plan, site, relations, deps) {
350
383
  }
351
384
  }
352
385
  export { fold };
386
+ /** The words of a page (texts and buttons), lowercase, three letters or more. */
387
+ function wordsOf(nodes) {
388
+ const out = new Set();
389
+ const go = (list) => {
390
+ for (const n of list) {
391
+ if (!n.hidden) {
392
+ const text = [n.text, ...(n.runs ?? []).map((r) => r.t)].filter(Boolean).join(' ');
393
+ for (const w of text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [])
394
+ out.add(w);
395
+ }
396
+ if (n.children)
397
+ go(n.children);
398
+ }
399
+ };
400
+ go(nodes);
401
+ return out;
402
+ }
403
+ function imagesOf(nodes) {
404
+ const out = new Set();
405
+ const go = (list) => {
406
+ for (const n of list) {
407
+ if (n.type === 'image' && n.src && !n.hidden)
408
+ out.add(n.src);
409
+ if (n.bgImage?.src)
410
+ out.add(n.bgImage.src);
411
+ if (n.children)
412
+ go(n.children);
413
+ }
414
+ };
415
+ go(nodes);
416
+ return out;
417
+ }
418
+ /**
419
+ * Whether a page laid out again by the AI still says what it said: no text lost, nothing made up
420
+ * (library sample texts left behind count as made up), every image still there. A reason when not.
421
+ */
422
+ export function relayoutProblem(before, after) {
423
+ const was = wordsOf(before);
424
+ const now = wordsOf(after);
425
+ if (!now.size)
426
+ return 'la pagina sarebbe rimasta vuota';
427
+ const lost = [...was].filter((w) => !now.has(w)).length;
428
+ const added = [...now].filter((w) => !was.has(w)).length;
429
+ if (lost > Math.max(3, was.size * 0.1))
430
+ return 'l’AI avrebbe tolto parte del testo';
431
+ if (added > Math.max(8, was.size * 0.15))
432
+ return 'l’AI avrebbe aggiunto testi che non c’erano';
433
+ const pictures = imagesOf(after);
434
+ if ([...imagesOf(before)].some((src) => !pictures.has(src)))
435
+ return 'l’AI avrebbe tolto delle immagini';
436
+ return null;
437
+ }
438
+ /** Descriptions the page had for its images, put back where the new layout left them empty. */
439
+ export function restoreAlts(before, after) {
440
+ const alts = new Map();
441
+ const read = (list) => {
442
+ for (const n of list) {
443
+ if (n.type === 'image' && n.src && n.alt)
444
+ alts.set(n.src, n.alt);
445
+ if (n.bgImage?.src && n.bgImage.alt)
446
+ alts.set(n.bgImage.src, n.bgImage.alt);
447
+ if (n.children)
448
+ read(n.children);
449
+ }
450
+ };
451
+ read(before);
452
+ const fix = (n) => {
453
+ let out = n;
454
+ if (n.type === 'image' && n.src && !n.alt?.trim() && alts.has(n.src))
455
+ out = { ...out, alt: alts.get(n.src) };
456
+ if (n.bgImage?.src && !n.bgImage.alt?.trim() && alts.has(n.bgImage.src))
457
+ out = { ...out, bgImage: { ...n.bgImage, alt: alts.get(n.bgImage.src) ?? '' } };
458
+ return n.children ? { ...out, children: n.children.map(fix) } : out;
459
+ };
460
+ return after.map(fix);
461
+ }