@materializecms/server 0.8.0 → 0.9.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/remote.js CHANGED
@@ -1,70 +1,235 @@
1
- import { lookup } from 'node:dns/promises';
1
+ import { lookup as dnsLookup } from 'node:dns';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
2
4
  import { isIP } from 'node:net';
3
- /** Raised when a remote file cannot be fetched: the message says why, in words. */
5
+ import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib';
6
+ import { t } from './t.js';
7
+ /**
8
+ * Raised when a remote file cannot be fetched: the message says why, in words (in the reader's
9
+ * language); `reason` says it to code.
10
+ */
4
11
  export class RemoteError extends Error {
12
+ reason;
13
+ constructor(message, reason) {
14
+ super(message);
15
+ this.reason = reason;
16
+ }
17
+ }
18
+ /** The four numbers of an IPv4 address written the usual way, or null. */
19
+ function ipv4Parts(address) {
20
+ const parts = address.split('.');
21
+ if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p) || Number(p) > 255))
22
+ return null;
23
+ return parts.map(Number);
24
+ }
25
+ /** Loopback, private, shared, link-local, test and multicast IPv4 ranges: no public file lives there. */
26
+ function privateV4([a = 0, b = 0, c = 0]) {
27
+ return (a === 0 ||
28
+ a === 10 ||
29
+ a === 127 ||
30
+ (a === 100 && b >= 64 && b <= 127) ||
31
+ (a === 169 && b === 254) ||
32
+ (a === 172 && b >= 16 && b <= 31) ||
33
+ (a === 192 && b === 0 && (c === 0 || c === 2)) ||
34
+ (a === 192 && b === 168) ||
35
+ (a === 198 && (b === 18 || b === 19)) ||
36
+ (a === 198 && b === 51 && c === 100) ||
37
+ (a === 203 && b === 0 && c === 113) ||
38
+ a >= 224);
39
+ }
40
+ /**
41
+ * The eight 16-bit groups of an IPv6 address, with "::" filled in and a dotted IPv4 tail
42
+ * (::ffff:1.2.3.4) turned into its two groups; null when it is not one.
43
+ */
44
+ function ipv6Groups(address) {
45
+ let a = address.toLowerCase().replace(/^\[|\]$/g, '');
46
+ const zone = a.indexOf('%');
47
+ if (zone >= 0)
48
+ a = a.slice(0, zone);
49
+ if (a.includes('.')) {
50
+ const at = a.lastIndexOf(':');
51
+ const v4 = ipv4Parts(a.slice(at + 1));
52
+ if (!v4)
53
+ return null;
54
+ a = `${a.slice(0, at + 1)}${(((v4[0] ?? 0) << 8) | (v4[1] ?? 0)).toString(16)}:${(((v4[2] ?? 0) << 8) | (v4[3] ?? 0)).toString(16)}`;
55
+ }
56
+ const halves = a.split('::');
57
+ if (halves.length > 2)
58
+ return null;
59
+ const read = (s) => (s ? s.split(':') : []);
60
+ const head = read(halves[0] ?? '');
61
+ const tail = read(halves[1] ?? '');
62
+ const missing = 8 - head.length - tail.length;
63
+ if (halves.length === 1 ? missing !== 0 : missing < 1)
64
+ return null;
65
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail];
66
+ if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g)))
67
+ return null;
68
+ return groups.map((g) => Number.parseInt(g, 16));
69
+ }
70
+ /** The IPv4 address carried in two IPv6 groups. */
71
+ const v4Of = (hi, lo) => [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
72
+ /**
73
+ * Loopback, private, link-local and other addresses a public file never lives at. IPv6 is read
74
+ * group by group, so the same IPv4 address cannot slip through written another way (mapped as
75
+ * ::ffff:7f00:1, compatible, NAT64, 6to4). Anything that is not an address counts as private.
76
+ */
77
+ export function isPrivate(address) {
78
+ const v4 = ipv4Parts(address);
79
+ if (v4)
80
+ return privateV4(v4);
81
+ const g = ipv6Groups(address);
82
+ if (!g)
83
+ return true;
84
+ const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = g;
85
+ const zeros = (...xs) => xs.every((x) => x === 0);
86
+ // ::, ::1 and the old IPv4-compatible ::a.b.c.d, which no public server uses.
87
+ if (zeros(g0, g1, g2, g3, g4, g5))
88
+ return true;
89
+ // IPv4 inside (::ffff:a.b.c.d, ::ffff:0:a.b.c.d): judged as that IPv4.
90
+ if (zeros(g0, g1, g2, g3, g4) && g5 === 0xffff)
91
+ return privateV4(v4Of(g6, g7));
92
+ if (zeros(g0, g1, g2, g3) && g4 === 0xffff && g5 === 0)
93
+ return privateV4(v4Of(g6, g7));
94
+ // NAT64 (64:ff9b::/96 carries an IPv4; 64:ff9b:1::/48 is for local networks).
95
+ if (g0 === 0x64 && g1 === 0xff9b)
96
+ return g2 === 1 || !zeros(g2, g3, g4, g5) || privateV4(v4Of(g6, g7));
97
+ // 6to4: the IPv4 sits in the second and third groups.
98
+ if (g0 === 0x2002)
99
+ return privateV4(v4Of(g1, g2));
100
+ // Teredo (its IPv4 is scrambled) and the documentation range: never a real public file.
101
+ if (g0 === 0x2001 && (g1 === 0 || g1 === 0xdb8))
102
+ return true;
103
+ // Discard-only (100::/64).
104
+ if (g0 === 0x100 && zeros(g1, g2, g3))
105
+ return true;
106
+ return ((g0 & 0xfe00) === 0xfc00 || // unique local
107
+ (g0 & 0xffc0) === 0xfe80 || // link-local
108
+ (g0 & 0xffc0) === 0xfec0 || // site-local (old)
109
+ (g0 & 0xff00) === 0xff00 // multicast
110
+ );
5
111
  }
6
- /** Loopback, private, link-local and other addresses a public file never lives at. */
7
- function isPrivate(address) {
8
- if (isIP(address) === 6) {
9
- const a = address.toLowerCase();
10
- if (a.startsWith('::ffff:'))
11
- return isPrivate(a.slice(7));
12
- return a === '::1' || a === '::' || a.startsWith('fc') || a.startsWith('fd') || a.startsWith('fe80');
112
+ /**
113
+ * Resolves a name and refuses it when any of its addresses is private: run by the connection
114
+ * itself, so the address checked is the one connected to (a name cannot answer with a public
115
+ * address to the check and a private one a moment later).
116
+ */
117
+ const publicLookup = (hostname, options, callback) => {
118
+ dnsLookup(hostname, { ...options, all: true }, (error, found) => {
119
+ if (error)
120
+ return callback(error, '', 0);
121
+ if (!found.length || found.some((a) => isPrivate(a.address)))
122
+ return callback(new RemoteError(t('Questo indirizzo non è pubblico'), 'private'), '', 0);
123
+ if (options.all)
124
+ return callback(null, found);
125
+ const first = found[0];
126
+ callback(null, first.address, first.family);
127
+ });
128
+ };
129
+ /** Turns Node's response headers into the Headers callers read. */
130
+ function headersOf(res) {
131
+ const out = new Headers();
132
+ for (const [k, v] of Object.entries(res.headers)) {
133
+ if (v === undefined)
134
+ continue;
135
+ for (const one of Array.isArray(v) ? v : [v])
136
+ out.append(k, one);
13
137
  }
14
- const [x = 0, y = 0] = address.split('.').map(Number);
15
- return (x === 10 ||
16
- x === 127 ||
17
- x === 0 ||
18
- (x === 169 && y === 254) ||
19
- (x === 172 && y >= 16 && y <= 31) ||
20
- (x === 192 && y === 168) ||
21
- (x === 100 && y >= 64 && y <= 127) ||
22
- x >= 224);
138
+ return out;
139
+ }
140
+ /** One request, one answer: no redirects followed here. */
141
+ function send(url, options, headers) {
142
+ return new Promise((resolve, reject) => {
143
+ const req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, {
144
+ method: options.method ?? 'GET',
145
+ headers: {
146
+ ...headers,
147
+ ...(options.body !== undefined ? { 'content-length': String(Buffer.byteLength(options.body)) } : {}),
148
+ },
149
+ ...(options.allowPrivate ? {} : { lookup: publicLookup }),
150
+ signal: AbortSignal.timeout(options.timeout ?? 15_000),
151
+ });
152
+ req.on('response', resolve);
153
+ req.on('error', reject);
154
+ req.end(options.body);
155
+ });
156
+ }
157
+ /** The body as sent, uncompressed when the server compressed it anyway. */
158
+ function decoded(res) {
159
+ const encoding = (res.headers['content-encoding'] ?? '').toLowerCase().trim();
160
+ if (encoding === 'gzip' || encoding === 'x-gzip')
161
+ return res.pipe(createGunzip());
162
+ if (encoding === 'deflate')
163
+ return res.pipe(createInflate());
164
+ if (encoding === 'br')
165
+ return res.pipe(createBrotliDecompress());
166
+ return res;
23
167
  }
24
168
  /**
25
169
  * Fetches from the web: http(s) only, redirects checked one by one, at most `max` bytes. Addresses
26
170
  * on this machine or its network are refused, unless `allowPrivate` (an administrator importing
27
- * from a WordPress on their own network). Headers are sent to the first host only.
171
+ * from a WordPress on their own network), and checked when connecting, not before. Headers are
172
+ * sent to the first host only.
28
173
  */
29
174
  export async function remoteFetch(url, options) {
30
175
  let current = new URL(url);
31
176
  const origin = current.origin;
32
- for (let hop = 0; hop < 5; hop++) {
177
+ const hops = (options.maxRedirects ?? 4) + 1;
178
+ for (let hop = 0; hop < hops; hop++) {
33
179
  if (current.protocol !== 'https:' && current.protocol !== 'http:')
34
- throw new RemoteError('Only http and https addresses');
35
- if (!options.allowPrivate) {
36
- const host = current.hostname.replace(/^\[|\]$/g, '');
37
- const addresses = isIP(host) ? [host] : (await lookup(host, { all: true })).map((a) => a.address);
38
- if (!addresses.length || addresses.some(isPrivate))
39
- throw new RemoteError('This address is not public');
180
+ throw new RemoteError(t('Solo indirizzi http e https'));
181
+ // An address written as numbers never goes through the lookup: it is checked here.
182
+ const host = current.hostname.replace(/^\[|\]$/g, '');
183
+ if (!options.allowPrivate && isIP(host) && isPrivate(host))
184
+ throw new RemoteError(t('Questo indirizzo non è pubblico'), 'private');
185
+ let res;
186
+ try {
187
+ res = await send(current, options, current.origin === origin ? options.headers : undefined);
40
188
  }
41
- const res = await fetch(current, {
42
- redirect: 'manual',
43
- headers: current.origin === origin ? options.headers : undefined,
44
- signal: AbortSignal.timeout(options.timeout ?? 15_000),
45
- });
46
- if (res.status >= 300 && res.status < 400) {
47
- const to = res.headers.get('location');
189
+ catch (error) {
190
+ // The refusal from the lookup may come back wrapped by the connection: said the same way.
191
+ if (error instanceof RemoteError)
192
+ throw error;
193
+ if (error.cause instanceof RemoteError)
194
+ throw error.cause;
195
+ throw error;
196
+ }
197
+ const status = res.statusCode ?? 0;
198
+ if (status >= 300 && status < 400) {
199
+ res.resume();
200
+ const to = res.headers.location;
48
201
  if (!to)
49
- throw new RemoteError(`Redirect without a destination (${res.status})`);
202
+ throw new RemoteError(t('Reindirizzamento senza destinazione ({status})', { status }));
50
203
  current = new URL(to, current);
51
204
  continue;
52
205
  }
53
- const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
54
- if (Number(res.headers.get('content-length') ?? 0) > options.max)
55
- throw new RemoteError('The file is too large');
206
+ const type = (res.headers['content-type'] ?? '').split(';')[0]?.trim() ?? '';
207
+ if (Number(res.headers['content-length'] ?? 0) > options.max) {
208
+ res.destroy();
209
+ throw new RemoteError(t('Il file è troppo grande'), 'too_large');
210
+ }
56
211
  const chunks = [];
57
212
  let size = 0;
58
- for await (const chunk of res.body ?? []) {
59
- size += chunk.length;
60
- if (size > options.max)
61
- throw new RemoteError('The file is too large');
62
- chunks.push(Buffer.from(chunk));
213
+ const stream = decoded(res);
214
+ try {
215
+ for await (const chunk of stream) {
216
+ size += chunk.length;
217
+ // Counted after decompressing: a small compressed answer cannot fill the memory.
218
+ if (size > options.max)
219
+ throw new RemoteError(t('Il file è troppo grande'), 'too_large');
220
+ chunks.push(chunk);
221
+ }
222
+ }
223
+ finally {
224
+ if (size > options.max) {
225
+ stream.destroy();
226
+ res.destroy();
227
+ }
63
228
  }
64
229
  const name = decodeURIComponent(current.pathname.split('/').pop() || 'immagine');
65
- return { status: res.status, headers: res.headers, bytes: Buffer.concat(chunks), type, name, url: current.href };
230
+ return { status, headers: headersOf(res), bytes: Buffer.concat(chunks), type, name, url: current.href };
66
231
  }
67
- throw new RemoteError('Too many redirects');
232
+ throw new RemoteError(t('Troppi reindirizzamenti'));
68
233
  }
69
234
  /**
70
235
  * Downloads a public file (an image an AI found on the web): http(s) only, never to an address on
@@ -73,6 +238,6 @@ export async function remoteFetch(url, options) {
73
238
  export async function fetchPublicFile(url, max) {
74
239
  const res = await remoteFetch(url, { max });
75
240
  if (res.status < 200 || res.status >= 300)
76
- throw new RemoteError(`The server answered ${res.status}`);
241
+ throw new RemoteError(t('Il server ha risposto {status}', { status: res.status }));
77
242
  return { bytes: res.bytes, type: res.type, name: res.name };
78
243
  }
@@ -1,9 +1,11 @@
1
+ import { currentLang } from '@materializecms/core/lang';
1
2
  import { html } from 'hono/html';
3
+ import { t } from './t.js';
2
4
  /** Page served at "/" until the site renderer ships: confirms the server works and links to the admin. */
3
5
  export function statusPage(config, version, needsSetup) {
4
6
  const rows = config.collections.map((c) => html `<li><span>${c.label}</span><code>/api/content/${c.name}</code></li>`);
5
7
  return html `<!doctype html>
6
- <html lang="it">
8
+ <html lang="${currentLang()}">
7
9
  <head>
8
10
  <meta charset="utf-8" />
9
11
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -27,12 +29,15 @@ export function statusPage(config, version, needsSetup) {
27
29
  <body>
28
30
  <main>
29
31
  <div class="mark" aria-hidden="true">M</div>
30
- <h1>${config.site.name} è online</h1>
31
- <p>Materialize ${version} · ${config.collections.length} collezioni · lingue: ${config.locales.available.join(', ')}</p>
32
+ <h1>${t('{site} è online', { site: config.site.name })}</h1>
33
+ <p>
34
+ Materialize ${version} · ${t.n(config.collections.length, '{n} collezione', '{n} collezioni')} ·
35
+ ${t('lingue: {list}', { list: config.locales.available.join(', ') })}
36
+ </p>
32
37
  <ul>
33
38
  ${rows}
34
39
  </ul>
35
- <a class="button" href="/admin/">${needsSetup ? 'Configura il pannello' : 'Apri il pannello'}</a>
40
+ <a class="button" href="/admin/">${needsSetup ? t('Configura il pannello') : t('Apri il pannello')}</a>
36
41
  </main>
37
42
  </body>
38
43
  </html>`;
package/src/t.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Texts of this package in the language of whoever reads them (see core/src/lang.ts). */
2
+ export declare const t: import("@materializecms/core/lang").Translate;
package/src/t.js ADDED
@@ -0,0 +1,4 @@
1
+ import { translator } from '@materializecms/core/lang';
2
+ import { en } from './en/index.js';
3
+ /** Texts of this package in the language of whoever reads them (see core/src/lang.ts). */
4
+ export const t = translator(en);
package/src/tools.js CHANGED
@@ -1,7 +1,8 @@
1
- import { buildSection, LIBRARY } from '@materializecms/core/library';
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
3
  import { voicePrompt } from '@materializecms/core/voice';
4
4
  import { z } from 'zod';
5
+ import { t } from './t.js';
5
6
  /** An answer the model can act on: what went wrong, in words. */
6
7
  export class ToolError extends Error {
7
8
  }
@@ -101,7 +102,7 @@ export function applyOperations(nodes, ops, brand) {
101
102
  const need = (id) => findNode(tree, id) ?? fail(`there is no node "${id}"`);
102
103
  switch (op.op) {
103
104
  case 'add_section': {
104
- if (!LIBRARY.some((c) => c.variants.some((v) => v.key === op.key)))
105
+ if (!library().some((c) => c.variants.some((v) => v.key === op.key)))
105
106
  fail(`no library section "${op.key}"`);
106
107
  tree = insertNode(tree, null, op.index ?? tree.length, buildSection(op.key, brand));
107
108
  break;
@@ -171,7 +172,7 @@ export function pageTools(options) {
171
172
  return [
172
173
  tool({
173
174
  name: 'get_page',
174
- title: 'Leggo la pagina',
175
+ title: t('Leggo la pagina'),
175
176
  description: 'The page as an outline: one line per node with its id, type, name and text. Read it before editing, and again after big changes.',
176
177
  input: z.object({ ...id }),
177
178
  readOnly: true,
@@ -179,7 +180,7 @@ export function pageTools(options) {
179
180
  }),
180
181
  tool({
181
182
  name: 'get_node',
182
- title: 'Leggo un elemento',
183
+ title: t('Leggo un elemento'),
183
184
  description: 'One node with all its properties and children, as JSON: to see its exact design before changing it.',
184
185
  input: z.object({ ...id, nodeId: z.string() }),
185
186
  readOnly: true,
@@ -192,7 +193,7 @@ export function pageTools(options) {
192
193
  }),
193
194
  tool({
194
195
  name: 'edit_page',
195
- title: 'Modifico la pagina',
196
+ title: t('Modifico la pagina'),
196
197
  description: `Changes the page with a list of operations, applied in order and saved together (all or nothing): add_section from the library, insert a node, update properties, replace, remove, move, duplicate. Returns the new outline.\n\n${PAGE_GUIDE}`,
197
198
  input: z.object({ ...id, operations: z.array(operation).min(1).max(60) }),
198
199
  run: async (input) => {
@@ -274,7 +275,7 @@ export function contentTools(call, getSchema) {
274
275
  return [
275
276
  tool({
276
277
  name: 'get_site',
277
- title: 'Guardo il sito',
278
+ title: t('Guardo il sito'),
278
279
  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.',
279
280
  input: z.object({}),
280
281
  readOnly: true,
@@ -296,7 +297,7 @@ export function contentTools(call, getSchema) {
296
297
  }),
297
298
  tool({
298
299
  name: 'list_entries',
299
- title: 'Elenco i contenuti',
300
+ title: t('Elenco i contenuti'),
300
301
  description: 'Entries of a collection (newest changes first), with id, title, address and whether published.',
301
302
  input: z.object({
302
303
  collection: z.string(),
@@ -316,7 +317,7 @@ export function contentTools(call, getSchema) {
316
317
  }),
317
318
  tool({
318
319
  name: 'get_entry',
319
- title: 'Leggo un contenuto',
320
+ title: t('Leggo un contenuto'),
320
321
  description: 'An entry with all its fields. For pages built on the canvas the page field is long: use get_page to read it as an outline.',
321
322
  input: z.object({ id: z.string() }),
322
323
  readOnly: true,
@@ -333,7 +334,7 @@ export function contentTools(call, getSchema) {
333
334
  }),
334
335
  tool({
335
336
  name: 'create_entry',
336
- title: 'Creo un contenuto',
337
+ title: t('Creo un contenuto'),
337
338
  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. Publish it with publish_entry.',
338
339
  input: z.object({
339
340
  collection: z.string(),
@@ -347,7 +348,7 @@ export function contentTools(call, getSchema) {
347
348
  }),
348
349
  tool({
349
350
  name: 'update_entry',
350
- title: 'Modifico un contenuto',
351
+ title: t('Modifico un contenuto'),
351
352
  description: 'Changes some fields of an entry (the others stay as they are) and saves the draft. Use edit_page for the design of a page.',
352
353
  input: z.object({ id: z.string(), data: z.record(z.string(), z.unknown()) }),
353
354
  run: async (input) => {
@@ -360,7 +361,7 @@ export function contentTools(call, getSchema) {
360
361
  }),
361
362
  tool({
362
363
  name: 'publish_entry',
363
- title: 'Pubblico',
364
+ title: t('Pubblico'),
364
365
  description: 'Puts the current draft of an entry on the public site.',
365
366
  input: z.object({ id: z.string() }),
366
367
  run: async (input) => {
@@ -370,7 +371,7 @@ export function contentTools(call, getSchema) {
370
371
  }),
371
372
  tool({
372
373
  name: 'unpublish_entry',
373
- title: 'Tolgo dal sito',
374
+ title: t('Tolgo dal sito'),
374
375
  description: 'Takes an entry off the public site; it stays as a draft.',
375
376
  input: z.object({ id: z.string() }),
376
377
  run: async (input) => {
@@ -380,7 +381,7 @@ export function contentTools(call, getSchema) {
380
381
  }),
381
382
  tool({
382
383
  name: 'schedule_entry',
383
- title: 'Programmo',
384
+ title: t('Programmo'),
384
385
  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
386
  input: z.object({
386
387
  id: z.string(),
@@ -399,7 +400,7 @@ export function contentTools(call, getSchema) {
399
400
  }),
400
401
  tool({
401
402
  name: 'share_preview',
402
- title: 'Creo un link di anteprima',
403
+ title: t('Creo un link di anteprima'),
403
404
  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
405
  input: z.object({
405
406
  id: z.string(),
@@ -412,7 +413,7 @@ export function contentTools(call, getSchema) {
412
413
  }),
413
414
  tool({
414
415
  name: 'list_releases',
415
- title: 'Guardo i rilasci',
416
+ title: t('Guardo i rilasci'),
416
417
  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
418
  input: z.object({}),
418
419
  readOnly: true,
@@ -420,14 +421,14 @@ export function contentTools(call, getSchema) {
420
421
  }),
421
422
  tool({
422
423
  name: 'create_release',
423
- title: 'Creo un rilascio',
424
+ title: t('Creo un rilascio'),
424
425
  description: 'A new, empty release with a name (e.g. "Collezione inverno"). Add entries with add_to_release.',
425
426
  input: z.object({ name: z.string().min(1).max(120) }),
426
427
  run: (input) => call('POST', '/api/releases', input),
427
428
  }),
428
429
  tool({
429
430
  name: 'add_to_release',
430
- title: 'Aggiungo al rilascio',
431
+ title: t('Aggiungo al rilascio'),
431
432
  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
433
  input: z.object({
433
434
  release: z.string().describe('The release id'),
@@ -443,21 +444,21 @@ export function contentTools(call, getSchema) {
443
444
  }),
444
445
  tool({
445
446
  name: 'publish_release',
446
- title: 'Pubblico il rilascio',
447
+ title: t('Pubblico il rilascio'),
447
448
  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
449
  input: z.object({ release: z.string() }),
449
450
  run: (input) => call('POST', `/api/releases/${encodeURIComponent(input.release)}/publish`),
450
451
  }),
451
452
  tool({
452
453
  name: 'schedule_release',
453
- title: 'Programmo il rilascio',
454
+ title: t('Programmo il rilascio'),
454
455
  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
456
  input: z.object({ release: z.string(), at: z.string().nullable() }),
456
457
  run: (input) => call('PUT', `/api/releases/${encodeURIComponent(input.release)}/schedule`, { at: input.at }),
457
458
  }),
458
459
  tool({
459
460
  name: 'list_translations',
460
- title: 'Guardo le lingue',
461
+ title: t('Guardo le lingue'),
461
462
  description: 'The language versions of an entry (its original and translations, with id and status) and the site languages it is still missing.',
462
463
  input: z.object({ id: z.string() }),
463
464
  readOnly: true,
@@ -465,7 +466,7 @@ export function contentTools(call, getSchema) {
465
466
  }),
466
467
  tool({
467
468
  name: 'translate_entry',
468
- title: 'Traduco',
469
+ title: t('Traduco'),
469
470
  description: 'Creates the version of an entry in another site language, as a draft linked to the original. With translate: true the site AI translates every text (and the address); otherwise it is a copy to translate, e.g. with update_entry or edit_page. Menus and footers shared by pages are translated once per language in the editor.',
470
471
  input: z.object({
471
472
  id: z.string(),
@@ -482,15 +483,15 @@ export function contentTools(call, getSchema) {
482
483
  }),
483
484
  tool({
484
485
  name: 'list_library',
485
- title: 'Guardo la libreria di sezioni',
486
+ title: t('Guardo la libreria di sezioni'),
486
487
  description: 'Ready-made sections that can be added to a page (edit_page → add_section), by category.',
487
488
  input: z.object({}),
488
489
  readOnly: true,
489
- run: async () => LIBRARY.map((c) => ({ category: c.label, sections: c.variants.map((v) => `${v.key} (${v.label})`) })),
490
+ run: async () => library().map((c) => ({ category: c.label, sections: c.variants.map((v) => `${v.key} (${v.label})`) })),
490
491
  }),
491
492
  tool({
492
493
  name: 'get_theme',
493
- title: 'Leggo il tema',
494
+ title: t('Leggo il tema'),
494
495
  description: 'The design theme: colors (light and dark), fonts, roundness, text styles.',
495
496
  input: z.object({}),
496
497
  readOnly: true,
@@ -498,7 +499,7 @@ export function contentTools(call, getSchema) {
498
499
  }),
499
500
  tool({
500
501
  name: 'update_theme',
501
- title: 'Modifico il tema',
502
+ title: t('Modifico il tema'),
502
503
  description: 'Changes the theme: give only what changes, e.g. {"display": "Fraunces", "colors": {"light": {"accent": "#e94f37"}}}. Fonts are Google Fonts names. Applies to the whole site at once.',
503
504
  input: z.object({ patch: z.record(z.string(), z.unknown()) }),
504
505
  run: async (input) => {
@@ -508,7 +509,7 @@ export function contentTools(call, getSchema) {
508
509
  }),
509
510
  tool({
510
511
  name: 'list_media',
511
- title: 'Guardo la libreria media',
512
+ title: t('Guardo la libreria media'),
512
513
  description: 'Images and files already uploaded, with their address (src) to use in pages and fields.',
513
514
  input: z.object({ search: z.string().optional(), limit: z.number().int().min(1).max(100).optional() }),
514
515
  readOnly: true,
@@ -521,7 +522,7 @@ export function contentTools(call, getSchema) {
521
522
  }),
522
523
  tool({
523
524
  name: 'upload_media_from_url',
524
- title: 'Aggiungo un’immagine dal web',
525
+ title: t('Aggiungo un’immagine dal web'),
525
526
  description: 'Downloads an image from a public https address into the media library and returns its src. Only use images the site may publish.',
526
527
  input: z.object({ url: z.url(), alt: z.string().max(500).optional() }),
527
528
  run: async (input) => call('POST', '/api/media/from-url', input),
package/src/wordpress.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { newId } from '@materializecms/core/page';
2
2
  import { DomUtils, parseDocument } from 'htmlparser2';
3
+ import { t } from './t.js';
3
4
  export class WordPressError extends Error {
4
5
  }
5
6
  const parse = (html, xml = false) => parseDocument(html, xml ? { xmlMode: true } : { decodeEntities: true });
@@ -40,7 +41,7 @@ export async function readRest(base, fetcher, options = {}) {
40
41
  }
41
42
  }
42
43
  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
+ throw new WordPressError(t('Non trovo l’API di WordPress a questo indirizzo: controlla che sia il sito giusto, oppure usa il file di esportazione.'));
44
45
  const endpoint = api;
45
46
  const items = [];
46
47
  for (const kind of ['post', 'page']) {
@@ -56,9 +57,9 @@ export async function readRest(base, fetcher, options = {}) {
56
57
  if (res.status === 400 && page > 1)
57
58
  break;
58
59
  if (res.status === 401 || res.status === 403)
59
- throw new WordPressError('WordPress non dà accesso ai contenuti: controlla utente e password applicativa.');
60
+ throw new WordPressError(t('WordPress non dà accesso ai contenuti: controlla utente e password applicativa.'));
60
61
  if (res.status !== 200)
61
- throw new WordPressError(`WordPress ha risposto ${res.status} leggendo ${route}`);
62
+ throw new WordPressError(t('WordPress ha risposto {status} leggendo {route}', { status: res.status, route }));
62
63
  const list = JSON.parse(res.text);
63
64
  for (const p of list)
64
65
  items.push(fromRest(p, kind));
@@ -118,7 +119,7 @@ export function readWxr(xml) {
118
119
  };
119
120
  const channel = find(doc, 'channel');
120
121
  if (!channel || !find(channel, 'wp:wxr_version'))
121
- throw new WordPressError('Questo non è un file di esportazione di WordPress (Strumenti → Esporta).');
122
+ throw new WordPressError(t('Questo non è un file di esportazione di WordPress (Strumenti → Esporta).'));
122
123
  const child = (node, name) => (node.children ?? []).find((c) => c.name === name);
123
124
  const text = (node) => (node ? textOf(node).trim() : '');
124
125
  const rawItems = (channel.children ?? []).filter((c) => c.name === 'item');
@@ -499,7 +500,7 @@ export function htmlToPage(html, title, ctx) {
499
500
  const sections = [];
500
501
  let current = null;
501
502
  const section = () => {
502
- current = { id: newId(), type: 'frame', name: 'Sezione', layout: 'col', gap: 20, size: 'm', children: [] };
503
+ current = { id: newId(), type: 'frame', name: t('Sezione'), layout: 'col', gap: 20, size: 'm', children: [] };
503
504
  sections.push(current);
504
505
  return current;
505
506
  };
@@ -527,7 +528,7 @@ export function htmlToPage(html, title, ctx) {
527
528
  add({
528
529
  id: newId(),
529
530
  type: 'image',
530
- name: 'Immagine',
531
+ name: t('Immagine'),
531
532
  src: img.src,
532
533
  alt: p.alt || img.alt,
533
534
  ratio: img.width && img.height ? `${img.width}/${img.height}` : '16/9',
@@ -540,7 +541,7 @@ export function htmlToPage(html, title, ctx) {
540
541
  add({
541
542
  id: newId(),
542
543
  type: 'button',
543
- name: 'Pulsante',
544
+ name: t('Pulsante'),
544
545
  text: p.text,
545
546
  href: p.href,
546
547
  variant: 'primary',
@@ -568,12 +569,12 @@ export function htmlToPage(html, title, ctx) {
568
569
  ? p.runs.map((r) => ({ ...r, i: true }))
569
570
  : p.runs;
570
571
  add(text(runs, style, p.style === 'li'
571
- ? 'Punto elenco'
572
+ ? t('Punto elenco')
572
573
  : p.style.startsWith('h')
573
- ? 'Titolo'
574
+ ? t('Titolo')
574
575
  : p.style === 'quote'
575
- ? 'Citazione'
576
- : 'Testo'));
576
+ ? t('Citazione')
577
+ : t('Testo')));
577
578
  }
578
579
  return sections;
579
580
  }