@materializecms/server 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,24 @@
1
+ # Novità di Materialize
2
+
3
+ Ogni versione, dalla più recente. Le novità in arrivo si scrivono sotto «Prossima versione»: `npm run bump` le assegna alla nuova versione.
4
+
5
+ ## Prossima versione
6
+
7
+ ## 0.1.0 — 2026-10-31
8
+
9
+ La prima versione pubblica.
10
+
11
+ - **Editor visuale**: pagine disegnate su una tela, con sezioni, colonne, testi, immagini, video, icone e forme; componenti riutilizzabili con varianti; righelli, guide, allineamenti e misure; modalità Contenuti per chi modifica solo i testi.
12
+ - **Animazioni ed effetti**: animazioni allo scorrimento e con linee temporali, stati al passaggio del mouse, sfondi WebGL e oggetti 3D.
13
+ - **Contenuti**: raccolte come Progetti, Diario e Team, con pagine modello; azioni rapide e selezione multipla negli elenchi e nei Media.
14
+ - **Siti in più lingue**: traduzioni collegate tra loro, anche con l’AI, e un selettore di lingua per il sito.
15
+ - **SEO**: titoli e descrizioni, sitemap, controlli sulla pagina e reindirizzamenti automatici quando cambia un indirizzo.
16
+ - **Moduli di contatto** con invio delle email dal tuo server di posta.
17
+ - **Assistente AI e server MCP**: testi, traduzioni e modifiche alle pagine chiedendole a parole.
18
+ - **Importazione da WordPress**: pagine, articoli e immagini.
19
+ - **Statistiche e cookie**: Google Analytics, Tag Manager, Meta Pixel, Plausible, Matomo e codice personalizzato, con il banner per il consenso.
20
+ - **Installazione su VPS con un comando**: Node.js, HTTPS con Caddy, servizio che riparte da solo, firewall e link per finire nel browser.
21
+ - **Immagine Docker** con `compose.yaml` pronto per l'HTTPS: il sito sta in un volume, quindi gli aggiornamenti dal pannello restano anche ricreando il container.
22
+ - **Installazione nel browser**: controllo del server, nome e lingua del sito, account amministratore, passo per passo.
23
+ - **Aggiornamenti con un clic** da Impostazioni → Aggiornamenti, con backup prima e ritorno automatico alla versione precedente se la nuova non parte; le correzioni possono installarsi da sole.
24
+ - **Sicurezza**: verifica in due passaggi, pausa dopo i tentativi di accesso falliti, registro delle attività, backup giornalieri del database e dei media.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cristiano Casazza
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @materializecms/server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.
4
+
5
+ Lo avvia il comando `materialize` (`@materializecms/cli`): di solito non serve importarlo direttamente.
6
+
7
+ ## Materialize
8
+
9
+ CMS open source con editor visuale: le pagine si disegnano sulla tela, il sito è fatto di file statici veloci. Per un nuovo sito (serve Node 24):
10
+
11
+ ```bash
12
+ npm create materialize@latest
13
+ ```
14
+
15
+ Documentazione e codice: https://github.com/materializecms/materialize · Licenza MIT.
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@materializecms/server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Il server di Materialize: API, accessi, media, backup, aggiornamenti dal pannello e server MCP.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/materializecms/materialize.git",
10
+ "directory": "packages/server"
11
+ },
12
+ "homepage": "https://github.com/materializecms/materialize#readme",
13
+ "bugs": "https://github.com/materializecms/materialize/issues",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.d.ts",
17
+ "default": "./src/index.js"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "@anthropic-ai/sdk": "1.3.0",
22
+ "@hono/node-server": "2.1.5",
23
+ "@materializecms/admin": "0.1.0",
24
+ "@materializecms/core": "0.1.0",
25
+ "@materializecms/db": "0.1.0",
26
+ "@modelcontextprotocol/sdk": "1.32.2",
27
+ "hono": "4.13.14",
28
+ "htmlparser2": "12.0.0",
29
+ "nodemailer": "10.2.1",
30
+ "sharp": "0.35.5",
31
+ "zod": "4.6.7"
32
+ },
33
+ "engines": {
34
+ "node": ">=24"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
package/src/admin.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Context } from 'hono';
2
+ /**
3
+ * Where previews run code the panel's policy would block (custom code, the site's own scripts):
4
+ * an empty page, same origin, with no policy of its own. The panel fills it: the site preview is
5
+ * written in, custom code goes into a sandboxed frame inside it.
6
+ */
7
+ export declare const FRAME_PATH = "/admin/frame.html";
8
+ /**
9
+ * Serves the built admin app from `dir`. Hashed files under assets/ are cached forever;
10
+ * any other path without an extension falls back to index.html (client-side routing).
11
+ */
12
+ export declare function serveAdmin(c: Context, dir: string | undefined): Promise<Response>;
package/src/admin.js ADDED
@@ -0,0 +1,92 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { extname, join, normalize, sep } from 'node:path';
4
+ const TYPES = {
5
+ '.html': 'text/html; charset=utf-8',
6
+ '.js': 'text/javascript; charset=utf-8',
7
+ '.css': 'text/css; charset=utf-8',
8
+ '.json': 'application/json',
9
+ '.svg': 'image/svg+xml',
10
+ '.png': 'image/png',
11
+ '.ico': 'image/x-icon',
12
+ '.woff2': 'font/woff2',
13
+ '.woff': 'font/woff',
14
+ };
15
+ async function isFile(path) {
16
+ return (await stat(path).catch(() => null))?.isFile() ?? false;
17
+ }
18
+ /**
19
+ * The panel's Content Security Policy: scripts only from the panel itself (plus the small inline
20
+ * script that applies the theme, allowed by its hash), data sent only to this server, no plugins,
21
+ * and the panel cannot be framed by other sites. Styles and pictures stay open: pages on the
22
+ * canvas carry inline styles and images from anywhere.
23
+ */
24
+ function policy(inlineScripts) {
25
+ const hashes = inlineScripts.map((s) => `'sha256-${createHash('sha256').update(s).digest('base64')}'`);
26
+ return [
27
+ "default-src 'self'",
28
+ `script-src 'self' ${hashes.join(' ')}`.trim(),
29
+ "style-src 'self' 'unsafe-inline'",
30
+ 'img-src * data: blob:',
31
+ 'media-src * data: blob:',
32
+ "font-src 'self' data:",
33
+ "connect-src 'self'",
34
+ "frame-src 'self' https: blob: data:",
35
+ "worker-src 'self' blob:",
36
+ "object-src 'none'",
37
+ "base-uri 'self'",
38
+ "form-action 'self'",
39
+ "frame-ancestors 'self'",
40
+ ].join('; ');
41
+ }
42
+ /**
43
+ * Where previews run code the panel's policy would block (custom code, the site's own scripts):
44
+ * an empty page, same origin, with no policy of its own. The panel fills it: the site preview is
45
+ * written in, custom code goes into a sandboxed frame inside it.
46
+ */
47
+ export const FRAME_PATH = '/admin/frame.html';
48
+ const FRAME_HTML = '<!doctype html><html data-mz-blank style="height:100%"><meta charset="utf-8"><title></title><body style="margin:0;height:100%"></body></html>';
49
+ let cachedIndex = null;
50
+ async function indexOf(path) {
51
+ const html = await readFile(path, 'utf8');
52
+ if (cachedIndex?.path === path && cachedIndex.html === html)
53
+ return cachedIndex;
54
+ const inline = [...html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
55
+ cachedIndex = { path, html, csp: policy(inline) };
56
+ return cachedIndex;
57
+ }
58
+ /**
59
+ * Serves the built admin app from `dir`. Hashed files under assets/ are cached forever;
60
+ * any other path without an extension falls back to index.html (client-side routing).
61
+ */
62
+ export async function serveAdmin(c, dir) {
63
+ const index = dir ? join(dir, 'index.html') : '';
64
+ if (!dir || !(await isFile(index))) {
65
+ return c.html('<!doctype html><meta charset="utf-8"><title>Materialize</title><body style="font:15px system-ui;padding:40px">' +
66
+ '<h1 style="font-size:20px">Il pannello non è ancora compilato</h1>' +
67
+ '<p>Esegui <code>npm run build</code> nella cartella di Materialize, poi ricarica la pagina.</p>', 503);
68
+ }
69
+ if (c.req.path === FRAME_PATH)
70
+ return c.body(FRAME_HTML, 200, {
71
+ 'content-type': TYPES['.html'],
72
+ 'cache-control': 'no-cache',
73
+ 'content-security-policy': "frame-ancestors 'self'",
74
+ });
75
+ const relative = decodeURIComponent(c.req.path.replace(/^\/admin\/?/, ''));
76
+ const file = normalize(join(dir, relative));
77
+ if (relative && relative !== 'index.html' && file.startsWith(dir + sep) && (await isFile(file))) {
78
+ const immutable = relative.startsWith('assets/');
79
+ return c.body(await readFile(file), 200, {
80
+ 'content-type': TYPES[extname(file)] ?? 'application/octet-stream',
81
+ 'cache-control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache',
82
+ });
83
+ }
84
+ if (extname(relative))
85
+ return c.text('Not found', 404);
86
+ const page = await indexOf(index);
87
+ return c.body(page.html, 200, {
88
+ 'content-type': TYPES['.html'],
89
+ 'cache-control': 'no-cache',
90
+ 'content-security-policy': page.csp,
91
+ });
92
+ }
package/src/ai.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { type SiteTool } from './tools.ts';
2
+ /**
3
+ * The AI behind the panel's assistant and text actions: Claude through Anthropic's API, or any
4
+ * server that speaks OpenAI's Chat Completions (OpenAI itself, Ollama, LM Studio, OpenRouter…).
5
+ * Saved in Impostazioni → AI e MCP; the key never goes back to the browser.
6
+ */
7
+ export interface AiSettings {
8
+ provider: 'anthropic' | 'openai';
9
+ apiKey: string;
10
+ /** Empty: the provider's own address. For OpenAI-compatible servers, e.g. http://localhost:11434/v1. */
11
+ baseUrl: string;
12
+ /** Empty: the default (Claude Opus 5.5 for Anthropic). */
13
+ model: string;
14
+ }
15
+ export declare const EMPTY_AI: AiSettings;
16
+ export declare const DEFAULT_CLAUDE = "claude-opus-5-5";
17
+ /** Raised when the AI cannot answer: not set up, refused, or the provider failed. */
18
+ export declare class AiError extends Error {
19
+ }
20
+ /** What the panel may know: everything but the key. */
21
+ export declare function publicAi(s: AiSettings | null): {
22
+ provider: 'anthropic' | 'openai';
23
+ baseUrl: string;
24
+ model: string;
25
+ hasKey: boolean;
26
+ envKey: boolean;
27
+ ready: boolean;
28
+ };
29
+ /** Claude needs a key (saved, or ANTHROPIC_API_KEY); a compatible server needs an address and a model. */
30
+ export declare function isReady(s: AiSettings | null): s is AiSettings;
31
+ /** Progress of an assistant turn, sent to the panel as it happens. */
32
+ export type AiEvent = {
33
+ type: 'status';
34
+ text: string;
35
+ } | {
36
+ type: 'tool';
37
+ name: string;
38
+ title: string;
39
+ } | {
40
+ type: 'text';
41
+ text: string;
42
+ };
43
+ /** One conversation, kept by the server between turns in the provider's own format. */
44
+ export interface Conversation {
45
+ provider: AiSettings['provider'];
46
+ messages: unknown[];
47
+ }
48
+ /** Text in, text out: a rewrite, a translation, an alt text (with an image). */
49
+ export declare function complete(s: AiSettings, input: {
50
+ system: string;
51
+ prompt: string;
52
+ image?: {
53
+ data: string;
54
+ mime: string;
55
+ };
56
+ }): Promise<string>;
57
+ /**
58
+ * One turn of the assistant: the person's message, then as many tool calls as the model needs
59
+ * (at most `maxSteps`), then its answer. The conversation keeps everything for the next turn.
60
+ */
61
+ export declare function runTurn(s: AiSettings, conv: Conversation, input: {
62
+ system: string;
63
+ text: string;
64
+ tools: SiteTool[];
65
+ onEvent: (e: AiEvent) => void;
66
+ maxSteps?: number;
67
+ }): Promise<string>;
68
+ /**
69
+ * Translates a page's texts in batches, keeping their order: each batch goes out as a JSON list
70
+ * and must come back as a list of the same length. Marks like <b>, <i> and <a1> stay in place.
71
+ */
72
+ export declare function translateTexts(s: AiSettings, texts: string[], from: string, to: string, onProgress?: (done: number) => void): Promise<string[]>;
package/src/ai.js ADDED
@@ -0,0 +1,262 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { z } from 'zod';
3
+ import { ToolError } from './tools.js';
4
+ export const EMPTY_AI = { provider: 'anthropic', apiKey: '', baseUrl: '', model: '' };
5
+ export const DEFAULT_CLAUDE = 'claude-opus-5-5';
6
+ /** Raised when the AI cannot answer: not set up, refused, or the provider failed. */
7
+ export class AiError extends Error {
8
+ }
9
+ /** What the panel may know: everything but the key. */
10
+ export function publicAi(s) {
11
+ const ai = { ...EMPTY_AI, ...s };
12
+ const { apiKey, ...rest } = ai;
13
+ return { ...rest, hasKey: !!apiKey, envKey: !!process.env.ANTHROPIC_API_KEY, ready: isReady(ai) };
14
+ }
15
+ /** Claude needs a key (saved, or ANTHROPIC_API_KEY); a compatible server needs an address and a model. */
16
+ export function isReady(s) {
17
+ if (!s)
18
+ return !!process.env.ANTHROPIC_API_KEY;
19
+ return s.provider === 'anthropic' ? !!(s.apiKey || process.env.ANTHROPIC_API_KEY) : !!(s.baseUrl && s.model);
20
+ }
21
+ /** Text in, text out: a rewrite, a translation, an alt text (with an image). */
22
+ export async function complete(s, input) {
23
+ if (s.provider === 'openai') {
24
+ const content = input.image
25
+ ? [
26
+ { type: 'image_url', image_url: { url: `data:${input.image.mime};base64,${input.image.data}` } },
27
+ { type: 'text', text: input.prompt },
28
+ ]
29
+ : input.prompt;
30
+ const reply = await openaiChat(s, {
31
+ messages: [
32
+ { role: 'system', content: input.system },
33
+ { role: 'user', content },
34
+ ],
35
+ });
36
+ return (reply.content ?? '').trim();
37
+ }
38
+ const message = await claude(s, {
39
+ max_tokens: 8000,
40
+ output_config: { effort: 'low' },
41
+ system: input.system,
42
+ messages: [
43
+ {
44
+ role: 'user',
45
+ content: input.image
46
+ ? [
47
+ {
48
+ type: 'image',
49
+ source: { type: 'base64', media_type: input.image.mime, data: input.image.data },
50
+ },
51
+ { type: 'text', text: input.prompt },
52
+ ]
53
+ : input.prompt,
54
+ },
55
+ ],
56
+ });
57
+ return textOf(message).trim();
58
+ }
59
+ /**
60
+ * One turn of the assistant: the person's message, then as many tool calls as the model needs
61
+ * (at most `maxSteps`), then its answer. The conversation keeps everything for the next turn.
62
+ */
63
+ export async function runTurn(s, conv, input) {
64
+ const byName = new Map(input.tools.map((t) => [t.name, t]));
65
+ const runTool = async (name, raw) => {
66
+ const t = byName.get(name);
67
+ if (!t)
68
+ return { text: `There is no tool "${name}"`, error: true };
69
+ const parsed = t.input.safeParse(raw);
70
+ if (!parsed.success)
71
+ return { text: `Invalid input: ${z.prettifyError(parsed.error)}`, error: true };
72
+ input.onEvent({ type: 'tool', name, title: t.title });
73
+ try {
74
+ const out = await t.run(parsed.data);
75
+ return { text: typeof out === 'string' ? out : JSON.stringify(out), error: false };
76
+ }
77
+ catch (error) {
78
+ if (!(error instanceof ToolError))
79
+ console.error(error);
80
+ return { text: error instanceof Error ? error.message : String(error), error: true };
81
+ }
82
+ };
83
+ const maxSteps = input.maxSteps ?? 24;
84
+ if (s.provider === 'openai') {
85
+ const tools = input.tools.map((t) => ({
86
+ type: 'function',
87
+ function: { name: t.name, description: t.description, parameters: schemaOf(t) },
88
+ }));
89
+ const messages = conv.messages;
90
+ messages.push({ role: 'user', content: input.text });
91
+ for (let step = 0; step < maxSteps; step++) {
92
+ const reply = await openaiChat(s, { messages: [{ role: 'system', content: input.system }, ...messages], tools });
93
+ messages.push(reply);
94
+ if (!reply.tool_calls?.length)
95
+ return (reply.content ?? '').trim();
96
+ if (reply.content?.trim())
97
+ input.onEvent({ type: 'status', text: reply.content.trim() });
98
+ for (const call of reply.tool_calls) {
99
+ let args;
100
+ try {
101
+ args = JSON.parse(call.function.arguments || '{}');
102
+ }
103
+ catch {
104
+ args = undefined;
105
+ }
106
+ const result = args === undefined
107
+ ? { text: `INVALID_JSON: ${call.function.arguments}`, error: true }
108
+ : await runTool(call.function.name, args);
109
+ messages.push({ role: 'tool', tool_call_id: call.id, content: result.text });
110
+ }
111
+ }
112
+ return 'Mi sono fermato: servivano troppi passaggi. Dimmi se continuare.';
113
+ }
114
+ const tools = input.tools.map((t) => ({
115
+ name: t.name,
116
+ description: t.description,
117
+ input_schema: schemaOf(t),
118
+ }));
119
+ const messages = conv.messages;
120
+ messages.push({ role: 'user', content: input.text });
121
+ for (let step = 0; step < maxSteps; step++) {
122
+ const message = await claude(s, {
123
+ max_tokens: 16000,
124
+ output_config: { effort: 'medium' },
125
+ // Progress notes between tool calls, so the panel can say what is happening.
126
+ ...(updatesSupported(s) ? { thinking: { type: 'adaptive', display: 'updates' } } : {}),
127
+ system: input.system,
128
+ tools,
129
+ messages,
130
+ });
131
+ // Kept as returned (thinking blocks included): the next request must carry them unchanged.
132
+ messages.push({ role: 'assistant', content: message.content });
133
+ for (const block of message.content)
134
+ if (block.type === 'thinking' && block.thinking.trim())
135
+ input.onEvent({ type: 'status', text: block.thinking.trim() });
136
+ if (message.stop_reason === 'refusal')
137
+ throw new AiError('La richiesta è stata rifiutata dal modello.');
138
+ if (message.stop_reason !== 'tool_use')
139
+ return textOf(message).trim();
140
+ const calls = message.content.filter((b) => b.type === 'tool_use');
141
+ const results = await Promise.all(calls.map(async (call) => {
142
+ const r = await runTool(call.name, call.input);
143
+ return { type: 'tool_result', tool_use_id: call.id, content: r.text, is_error: r.error };
144
+ }));
145
+ // All results in one message, as the API expects for parallel calls.
146
+ messages.push({ role: 'user', content: results });
147
+ }
148
+ return 'Mi sono fermato: servivano troppi passaggi. Dimmi se continuare.';
149
+ }
150
+ // ---------- Claude ----------
151
+ /** Models that take the server-side fallback on a refusal and progress notes between tool calls. */
152
+ const CURRENT = /^claude-(opus-5-5|opus-5|sonnet-5-5|fable-5-1)$/;
153
+ const modelOf = (s) => s.model.trim() || DEFAULT_CLAUDE;
154
+ const updatesSupported = (s) => /^claude-(opus-5-5|sonnet-5-5|fable-5-1|fable-5)$/.test(modelOf(s));
155
+ async function claude(s, params) {
156
+ const client = new Anthropic({ apiKey: s.apiKey || undefined, baseURL: s.baseUrl || undefined });
157
+ const model = modelOf(s);
158
+ const betas = [];
159
+ // A declined request is retried on a suitable model by the API itself (Claude API only).
160
+ const fallback = CURRENT.test(model) && !s.baseUrl;
161
+ if (fallback)
162
+ betas.push('server-side-fallback-2026-07-01');
163
+ if ('thinking' in params && params.thinking?.display === 'updates')
164
+ betas.push('thinking-display-updates-2026-08-18');
165
+ try {
166
+ return await client.beta.messages.create({
167
+ ...params,
168
+ model,
169
+ ...(betas.length ? { betas } : {}),
170
+ ...(fallback ? { fallbacks: 'default' } : {}),
171
+ });
172
+ }
173
+ catch (error) {
174
+ if (error instanceof Anthropic.AuthenticationError)
175
+ throw new AiError('La chiave API di Anthropic non è valida.');
176
+ if (error instanceof Anthropic.PermissionDeniedError)
177
+ throw new AiError('La chiave non ha accesso a questo modello.');
178
+ if (error instanceof Anthropic.NotFoundError)
179
+ throw new AiError(`Il modello “${model}” non esiste.`);
180
+ if (error instanceof Anthropic.RateLimitError)
181
+ throw new AiError('Troppe richieste ad Anthropic: riprova tra poco.');
182
+ if (error instanceof Anthropic.BadRequestError)
183
+ throw new AiError(`Richiesta non valida: ${error.message}`);
184
+ if (error instanceof Anthropic.APIError)
185
+ throw new AiError(`Anthropic non risponde (${error.status ?? 'rete'}).`);
186
+ throw error;
187
+ }
188
+ }
189
+ function textOf(message) {
190
+ return message.content
191
+ .filter((b) => b.type === 'text')
192
+ .map((b) => b.text)
193
+ .join('\n');
194
+ }
195
+ /** A tool's input as JSON Schema, without the meta keys providers reject. */
196
+ function schemaOf(t) {
197
+ const { $schema: _, ...schema } = z.toJSONSchema(t.input, { io: 'input' });
198
+ return schema;
199
+ }
200
+ async function openaiChat(s, body) {
201
+ const base = (s.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
202
+ let res;
203
+ try {
204
+ res = await fetch(`${base}/chat/completions`, {
205
+ method: 'POST',
206
+ headers: { 'content-type': 'application/json', ...(s.apiKey ? { authorization: `Bearer ${s.apiKey}` } : {}) },
207
+ body: JSON.stringify({ model: s.model, ...body }),
208
+ signal: AbortSignal.timeout(300_000),
209
+ });
210
+ }
211
+ catch {
212
+ throw new AiError(`Il server AI non risponde su ${base}.`);
213
+ }
214
+ const json = (await res.json().catch(() => null));
215
+ if (!res.ok)
216
+ throw new AiError(json?.error?.message ?? `Il server AI ha risposto ${res.status}.`);
217
+ const message = json?.choices?.[0]?.message;
218
+ if (!message)
219
+ throw new AiError('Il server AI ha dato una risposta vuota.');
220
+ return { role: 'assistant', content: message.content ?? null, tool_calls: message.tool_calls };
221
+ }
222
+ // ---------- translation ----------
223
+ const languageName = (code) => {
224
+ try {
225
+ return `${new Intl.DisplayNames(['en'], { type: 'language' }).of(code) ?? code} (${code})`;
226
+ }
227
+ catch {
228
+ return code;
229
+ }
230
+ };
231
+ /**
232
+ * Translates a page's texts in batches, keeping their order: each batch goes out as a JSON list
233
+ * and must come back as a list of the same length. Marks like <b>, <i> and <a1> stay in place.
234
+ */
235
+ export async function translateTexts(s, texts, from, to, onProgress) {
236
+ const system = `You translate the texts of a website from ${languageName(from)} to ${languageName(to)}. You receive a JSON array of strings and reply with a JSON array of their translations: same length, same order, nothing else (no code fences, no notes). Keep the tags <b>, </b>, <i>, </i>, <a1>, </a1> and so on around the matching words, keep line breaks, numbers, brand names, e-mail addresses and URLs. Write naturally for native readers, as a professional copywriter would, with the same tone and roughly the same length.`;
237
+ const out = [];
238
+ const BATCH = 60;
239
+ for (let start = 0; start < texts.length; start += BATCH) {
240
+ const batch = texts.slice(start, start + BATCH);
241
+ let result = null;
242
+ for (let attempt = 0; attempt < 2 && !result; attempt++) {
243
+ const reply = await complete(s, { system, prompt: JSON.stringify(batch) });
244
+ result = parseList(reply, batch.length);
245
+ }
246
+ if (!result)
247
+ throw new AiError('Il modello non ha restituito la traduzione completa: riprova.');
248
+ out.push(...result);
249
+ onProgress?.(out.length);
250
+ }
251
+ return out;
252
+ }
253
+ function parseList(reply, length) {
254
+ const json = reply.slice(reply.indexOf('['), reply.lastIndexOf(']') + 1);
255
+ try {
256
+ const list = JSON.parse(json);
257
+ if (Array.isArray(list) && list.length === length && list.every((x) => typeof x === 'string'))
258
+ return list;
259
+ }
260
+ catch { }
261
+ return null;
262
+ }
package/src/app.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { type MaterializeConfig } from '@materializecms/core';
2
+ import { type Updates } from '@materializecms/core/updates';
3
+ import { type Database, type User } from '@materializecms/db';
4
+ import { Hono } from 'hono';
5
+ /** The installed version, from this package's package.json (same place in the sources and in the release). */
6
+ export declare const VERSION: string;
7
+ /** `viaToken`: signed in with a personal access token (an MCP client, a script) instead of the panel's cookie. */
8
+ type Env = {
9
+ Variables: {
10
+ user: User;
11
+ viaToken?: boolean;
12
+ };
13
+ };
14
+ export interface ServerContext {
15
+ /** Returns the current config; the CLI swaps it when materialize.config.ts changes. */
16
+ config: () => MaterializeConfig;
17
+ database: Database;
18
+ /** Folder with the built admin app, served at /admin. */
19
+ adminDir?: string;
20
+ /** True when a site renderer answers /_materialize/preview: the admin then shows the real site on the canvas. */
21
+ preview?: boolean;
22
+ /** Called after the live content changes (publish, unpublish, delete), e.g. to rebuild the static site. */
23
+ onPublish?: () => void;
24
+ /** Updates from the panel, when whoever started the server can apply them (materialize start). */
25
+ updates?: Updates;
26
+ /**
27
+ * Asked by the installation in the browser before the first account exists, so whoever finds a
28
+ * fresh server first cannot claim it: `materialize start` prints the link that carries it.
29
+ */
30
+ setupToken?: string;
31
+ }
32
+ export declare function createApp(base: ServerContext): Hono<Env, import("hono/types").BlankSchema, "/">;
33
+ export {};