@materializecms/server 0.4.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
+ }
package/src/forms.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type FormDefinition } from '@materializecms/core/page';
2
+ import type { SubmissionFile } from '@materializecms/db';
3
+ /** Settings for every form, kept in the "forms" setting. */
4
+ export interface FormsSettings {
5
+ /** Messages older than this many months are deleted with their files; unset keeps them. */
6
+ keepMonths?: number;
7
+ /** Signs webhook calls, so the receiver can tell they come from this site. */
8
+ webhookSecret?: string;
9
+ }
10
+ export interface FieldIssue {
11
+ field: string;
12
+ message: string;
13
+ }
14
+ /** A sent file, before it is saved. */
15
+ export interface IncomingFile {
16
+ field: string;
17
+ name: string;
18
+ type: string;
19
+ bytes: Uint8Array;
20
+ }
21
+ /**
22
+ * What a form sent, checked against the form on the page: only its fields, the required ones
23
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
24
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
25
+ */
26
+ export declare function readSubmission(raw: Record<string, unknown>, def: FormDefinition | null): Promise<{
27
+ data: Record<string, string>;
28
+ files: IncomingFile[];
29
+ issues: FieldIssue[];
30
+ }>;
31
+ /**
32
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
33
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
34
+ */
35
+ export declare function spamReason(data: Record<string, string>, elapsedMs: number | null): string | null;
36
+ /** The same words from the same form: two identical messages in a day are a repeat. */
37
+ export declare const fingerprintOf: (form: string, data: Record<string, string>) => string;
38
+ /** Saves a message's files in its own folder, out of reach of the public site. */
39
+ export declare function saveFiles(dir: string, id: string, files: IncomingFile[]): Promise<SubmissionFile[]>;
40
+ export declare const removeFiles: (dir: string, id: string) => Promise<void>;
41
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
42
+ export declare const webhookSignature: (secret: string, body: string) => string;
43
+ /**
44
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
45
+ * longer each time. Resolves with whether it was taken.
46
+ */
47
+ export declare function sendWebhook(url: string, payload: unknown, secret: string, wait?: (ms: number) => Promise<void>): Promise<boolean>;
package/src/forms.js ADDED
@@ -0,0 +1,153 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ import { FILE_TYPES, FORM_FILE_MAX } from '@materializecms/core/page';
5
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
6
+ const isFile = (v) => typeof v === 'object' && v !== null && 'arrayBuffer' in v && typeof v.name === 'string';
7
+ /**
8
+ * What a form sent, checked against the form on the page: only its fields, the required ones
9
+ * filled in, emails that look like emails, choices from the list, files of the right kind and
10
+ * size. Without a definition (a form the site built by hand) the values are taken as they come.
11
+ */
12
+ export async function readSubmission(raw, def) {
13
+ const data = {};
14
+ const files = [];
15
+ const issues = [];
16
+ if (!def) {
17
+ for (const [k, v] of Object.entries(raw)) {
18
+ if (k.startsWith('_') || typeof v !== 'string' || Object.keys(data).length >= 30)
19
+ continue;
20
+ data[k.slice(0, 80)] = v.slice(0, 5000);
21
+ }
22
+ return { data, files, issues };
23
+ }
24
+ for (const field of def.fields) {
25
+ const value = raw[field.name];
26
+ if (field.kind === 'file') {
27
+ const file = isFile(value) && value.size > 0 ? value : null;
28
+ if (!file) {
29
+ if (field.required)
30
+ issues.push({ field: field.name, message: 'Allega un file' });
31
+ continue;
32
+ }
33
+ const allowed = FILE_TYPES[field.accept ?? 'any'];
34
+ if (!allowed.includes(extname(file.name).toLowerCase())) {
35
+ issues.push({ field: field.name, message: `Questo tipo di file non è accettato (${allowed.join(', ')})` });
36
+ continue;
37
+ }
38
+ if (file.size > FORM_FILE_MAX) {
39
+ issues.push({ field: field.name, message: `Il file supera ${FORM_FILE_MAX / 1024 / 1024} MB` });
40
+ continue;
41
+ }
42
+ files.push({
43
+ field: field.name,
44
+ name: file.name,
45
+ type: file.type,
46
+ bytes: new Uint8Array(await file.arrayBuffer()),
47
+ });
48
+ data[field.name] = file.name;
49
+ continue;
50
+ }
51
+ const text = typeof value === 'string' ? value.trim().slice(0, 5000) : '';
52
+ if (field.kind === 'checkbox') {
53
+ if (text)
54
+ data[field.name] = 'sì';
55
+ else if (field.required)
56
+ issues.push({ field: field.name, message: 'Va spuntata per inviare' });
57
+ continue;
58
+ }
59
+ if (!text) {
60
+ if (field.required)
61
+ issues.push({ field: field.name, message: 'Campo obbligatorio' });
62
+ continue;
63
+ }
64
+ if (field.kind === 'email' && !EMAIL.test(text)) {
65
+ issues.push({ field: field.name, message: 'Scrivi un indirizzo email valido' });
66
+ continue;
67
+ }
68
+ if (field.kind === 'select' && field.options?.length && !field.options.includes(text)) {
69
+ issues.push({ field: field.name, message: 'Scegli una delle voci dell’elenco' });
70
+ continue;
71
+ }
72
+ data[field.name] = text;
73
+ }
74
+ return { data, files, issues };
75
+ }
76
+ /** A form filled in faster than this is a bot (people need a few seconds at least). */
77
+ const MIN_FILL_MS = 2500;
78
+ /**
79
+ * Why a message looks like spam, without a CAPTCHA: filled in at once, too many links, links
80
+ * hidden in markup. Null for a message that looks real. Repeats are checked by the caller.
81
+ */
82
+ export function spamReason(data, elapsedMs) {
83
+ if (elapsedMs !== null && elapsedMs >= 0 && elapsedMs < MIN_FILL_MS)
84
+ return 'Compilato in meno di 3 secondi';
85
+ const text = Object.values(data).join('\n');
86
+ if (/\[url=|<a\s+href|\[link=/i.test(text))
87
+ return 'Link nascosti nel testo';
88
+ const links = text.match(/https?:\/\/|www\./gi)?.length ?? 0;
89
+ if (links > 3)
90
+ return `Troppi link (${links})`;
91
+ return null;
92
+ }
93
+ /** The same words from the same form: two identical messages in a day are a repeat. */
94
+ export const fingerprintOf = (form, data) => createHash('sha256')
95
+ .update(form)
96
+ .update(JSON.stringify(Object.entries(data)
97
+ .sort(([a], [b]) => a.localeCompare(b))
98
+ .map(([k, v]) => [k, v.toLowerCase().replace(/\s+/g, ' ')])))
99
+ .digest('hex');
100
+ /** A file name safe on any disk, keeping its extension. */
101
+ const safeName = (name) => name
102
+ .normalize('NFKD')
103
+ .replace(/[^\w.-]+/g, '-')
104
+ .replace(/^-+|-+$/g, '')
105
+ .slice(-80) || 'file';
106
+ /** Saves a message's files in its own folder, out of reach of the public site. */
107
+ export async function saveFiles(dir, id, files) {
108
+ if (!files.length)
109
+ return [];
110
+ const folder = join(dir, id);
111
+ await mkdir(folder, { recursive: true });
112
+ const saved = [];
113
+ for (const [i, f] of files.entries()) {
114
+ const path = `${id}/${i + 1}-${safeName(f.name)}`;
115
+ await writeFile(join(dir, path), f.bytes);
116
+ saved.push({ field: f.field, name: f.name, size: f.bytes.length, type: f.type, path });
117
+ }
118
+ return saved;
119
+ }
120
+ export const removeFiles = (dir, id) => rm(join(dir, id), { recursive: true, force: true }).catch(() => { });
121
+ /** The signature of a webhook body: `sha256=` and the HMAC of the body with the site's secret. */
122
+ export const webhookSignature = (secret, body) => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
123
+ /**
124
+ * Posts a message to a webhook (Zapier, Make, n8n…), signed; tries three times, waiting a little
125
+ * longer each time. Resolves with whether it was taken.
126
+ */
127
+ export async function sendWebhook(url, payload, secret, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
128
+ const body = JSON.stringify(payload);
129
+ for (const delay of [0, 20_000, 120_000]) {
130
+ if (delay)
131
+ await wait(delay);
132
+ try {
133
+ const res = await fetch(url, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ 'user-agent': 'Materialize-Webhook',
138
+ 'x-materialize-signature': webhookSignature(secret, body),
139
+ },
140
+ body,
141
+ redirect: 'error',
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+ if (res.ok)
145
+ return true;
146
+ // The receiver refused it on purpose: trying again would not help.
147
+ if (res.status >= 400 && res.status < 500 && res.status !== 429)
148
+ return false;
149
+ }
150
+ catch { }
151
+ }
152
+ return false;
153
+ }
@@ -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) => t.name !== 'publish_entry' && t.name !== 'unpublish_entry'),
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,
@@ -368,6 +378,83 @@ export function contentTools(call, getSchema) {
368
378
  return { id: doc.id, title: doc.title, published: false };
369
379
  },
370
380
  }),
381
+ tool({
382
+ name: 'schedule_entry',
383
+ title: 'Programmo',
384
+ description: 'Sets when an entry goes on the public site (publish) or comes off it (unpublish) by itself, as an ISO date with time zone, e.g. 2026-11-10T09:00:00+01:00. at null cancels. The draft as it is at that moment is what gets published.',
385
+ input: z.object({
386
+ id: z.string(),
387
+ action: z.enum(['publish', 'unpublish']),
388
+ at: z.string().nullable(),
389
+ }),
390
+ run: async (input) => {
391
+ const doc = await call('PUT', `/api/documents/${encodeURIComponent(input.id)}/schedule`, { action: input.action, at: input.at });
392
+ return {
393
+ id: doc.id,
394
+ title: doc.title,
395
+ publishAt: doc.schedule.publish?.runAt ?? null,
396
+ unpublishAt: doc.schedule.unpublish?.runAt ?? null,
397
+ };
398
+ },
399
+ }),
400
+ tool({
401
+ name: 'share_preview',
402
+ title: 'Creo un link di anteprima',
403
+ description: 'A secret link that shows an entry’s latest saved draft with the site’s design to someone without an account (a client, a colleague). It follows later edits. days: how long it works (default 7), null until revoked.',
404
+ input: z.object({
405
+ id: z.string(),
406
+ days: z.number().int().min(1).max(365).nullable().optional(),
407
+ }),
408
+ run: async (input) => {
409
+ const link = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/previews`, { days: input.days === undefined ? 7 : input.days });
410
+ return { url: link.url, expiresAt: link.expiresAt };
411
+ },
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
+ }),
371
458
  tool({
372
459
  name: 'list_translations',
373
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). */