@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/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/admin.js +3 -2
- package/src/ai.js +17 -16
- package/src/app.d.ts +5 -0
- package/src/app.js +518 -264
- package/src/brief.js +3 -2
- package/src/en/api.d.ts +2 -0
- package/src/en/api.js +199 -0
- package/src/en/index.d.ts +5 -0
- package/src/en/index.js +7 -0
- package/src/en/services.d.ts +2 -0
- package/src/en/services.js +154 -0
- package/src/forms.d.ts +6 -3
- package/src/forms.js +22 -11
- package/src/index.d.ts +13 -0
- package/src/index.js +52 -2
- package/src/lang.d.ts +7 -0
- package/src/lang.js +17 -0
- package/src/mail.d.ts +6 -2
- package/src/mail.js +73 -40
- package/src/media.js +3 -2
- package/src/offsite.js +12 -9
- package/src/remote-storage.js +24 -23
- package/src/remote.d.ts +25 -8
- package/src/remote.js +210 -45
- package/src/status-page.js +9 -4
- package/src/t.d.ts +2 -0
- package/src/t.js +4 -0
- package/src/tools.js +28 -27
- package/src/wordpress.js +12 -11
- package/src/wpimport.js +27 -23
package/src/remote.js
CHANGED
|
@@ -1,70 +1,235 @@
|
|
|
1
|
-
import { lookup } from 'node:dns
|
|
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
|
-
|
|
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
|
-
/**
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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)
|
|
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
|
-
|
|
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('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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
|
|
54
|
-
if (Number(res.headers
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
230
|
+
return { status, headers: headersOf(res), bytes: Buffer.concat(chunks), type, name, url: current.href };
|
|
66
231
|
}
|
|
67
|
-
throw new RemoteError('
|
|
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(
|
|
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
|
}
|
package/src/status-page.js
CHANGED
|
@@ -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="
|
|
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}
|
|
31
|
-
<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
package/src/t.js
ADDED
package/src/tools.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { buildSection,
|
|
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 (!
|
|
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 () =>
|
|
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(
|
|
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
|
}
|