@materializecms/server 0.0.0-stage → 0.2.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 +32 -0
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/package.json +37 -4
- package/src/admin.d.ts +12 -0
- package/src/admin.js +92 -0
- package/src/ai.d.ts +72 -0
- package/src/ai.js +262 -0
- package/src/app.d.ts +33 -0
- package/src/app.js +2601 -0
- package/src/archive.d.ts +23 -0
- package/src/archive.js +158 -0
- package/src/backups.d.ts +42 -0
- package/src/backups.js +167 -0
- package/src/fonts.d.ts +19 -0
- package/src/fonts.js +150 -0
- package/src/index.d.ts +34 -0
- package/src/index.js +60 -0
- package/src/mail.d.ts +33 -0
- package/src/mail.js +36 -0
- package/src/mcp.d.ts +6 -0
- package/src/mcp.js +36 -0
- package/src/media.d.ts +46 -0
- package/src/media.js +126 -0
- package/src/password.d.ts +3 -0
- package/src/password.js +25 -0
- package/src/remote.d.ts +32 -0
- package/src/remote.js +78 -0
- package/src/status-page.d.ts +3 -0
- package/src/status-page.js +39 -0
- package/src/tools.d.ts +116 -0
- package/src/tools.js +474 -0
- package/src/totp.d.ts +13 -0
- package/src/totp.js +79 -0
- package/src/wordpress.d.ts +87 -0
- package/src/wordpress.js +562 -0
- package/src/wpimport.d.ts +97 -0
- package/src/wpimport.js +352 -0
package/src/mail.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Outgoing email settings, saved in the panel. The password never goes back to the browser. */
|
|
2
|
+
export interface MailSettings {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
/** tls: encrypted from the start (465) · starttls: upgraded after connecting (587) · none: plain (tests). */
|
|
6
|
+
security: 'tls' | 'starttls' | 'none';
|
|
7
|
+
user: string;
|
|
8
|
+
pass: string;
|
|
9
|
+
/** Sender shown to recipients, e.g. "Studio Rossi <sito@example.com>". */
|
|
10
|
+
from: string;
|
|
11
|
+
/** Where messages from the site's forms arrive. */
|
|
12
|
+
to: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const EMPTY_MAIL: MailSettings;
|
|
15
|
+
export declare function isConfigured(m: MailSettings | null): m is MailSettings;
|
|
16
|
+
/** What the panel may see: everything but the password, and whether one is saved. */
|
|
17
|
+
export declare function publicMail(m: MailSettings | null): {
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
security: 'tls' | 'starttls' | 'none';
|
|
21
|
+
user: string;
|
|
22
|
+
from: string;
|
|
23
|
+
to: string;
|
|
24
|
+
hasPassword: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare function sendMail(m: MailSettings, message: {
|
|
27
|
+
to: string;
|
|
28
|
+
subject: string;
|
|
29
|
+
text: string;
|
|
30
|
+
replyTo?: string;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
/** A message from a site form as plain text: one "Label: value" per field. */
|
|
33
|
+
export declare function submissionText(page: string, data: Record<string, string>): string;
|
package/src/mail.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
export const EMPTY_MAIL = {
|
|
3
|
+
host: '',
|
|
4
|
+
port: 587,
|
|
5
|
+
security: 'starttls',
|
|
6
|
+
user: '',
|
|
7
|
+
pass: '',
|
|
8
|
+
from: '',
|
|
9
|
+
to: '',
|
|
10
|
+
};
|
|
11
|
+
export function isConfigured(m) {
|
|
12
|
+
return !!m?.host && !!m.from;
|
|
13
|
+
}
|
|
14
|
+
/** What the panel may see: everything but the password, and whether one is saved. */
|
|
15
|
+
export function publicMail(m) {
|
|
16
|
+
const { pass, ...rest } = { ...EMPTY_MAIL, ...m };
|
|
17
|
+
return { ...rest, hasPassword: !!pass };
|
|
18
|
+
}
|
|
19
|
+
export async function sendMail(m, message) {
|
|
20
|
+
const transport = nodemailer.createTransport({
|
|
21
|
+
host: m.host,
|
|
22
|
+
port: m.port,
|
|
23
|
+
secure: m.security === 'tls',
|
|
24
|
+
requireTLS: m.security === 'starttls',
|
|
25
|
+
ignoreTLS: m.security === 'none',
|
|
26
|
+
auth: m.user ? { user: m.user, pass: m.pass } : undefined,
|
|
27
|
+
connectionTimeout: 10_000,
|
|
28
|
+
greetingTimeout: 10_000,
|
|
29
|
+
});
|
|
30
|
+
await transport.sendMail({ from: m.from, ...message });
|
|
31
|
+
}
|
|
32
|
+
/** A message from a site form as plain text: one "Label: value" per field. */
|
|
33
|
+
export function submissionText(page, data) {
|
|
34
|
+
const lines = Object.entries(data).map(([k, v]) => `${k}:\n${v}\n`);
|
|
35
|
+
return `Nuovo messaggio dal modulo del sito${page ? ` (pagina ${page})` : ''}.\n\n${lines.join('\n')}`;
|
|
36
|
+
}
|
package/src/mcp.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { type ApiCall, type SiteTool } from './tools.ts';
|
|
3
|
+
/** Registers the site's tools on an MCP server; each call runs as the person behind `call`. */
|
|
4
|
+
export declare function registerTools(server: McpServer, tools: SiteTool[]): void;
|
|
5
|
+
/** A fresh MCP server for one request (stateless): the tools act as the person who sent it. */
|
|
6
|
+
export declare function createMcpServer(call: ApiCall, version: string): McpServer;
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { PAGE_GUIDE, siteTools, ToolError } from './tools.js';
|
|
3
|
+
/** What an MCP client is told about the site when it connects. */
|
|
4
|
+
const INSTRUCTIONS = `This server edits a website built with Materialize, a CMS with a visual page editor.
|
|
5
|
+
Start with get_site to see the collections and their fields; list_entries and get_entry read content.
|
|
6
|
+
Pages built on the canvas are edited with get_page (outline) and edit_page (operations). Changes are saved as drafts: publish_entry puts them online, so publish only when asked.
|
|
7
|
+
${PAGE_GUIDE}`;
|
|
8
|
+
/** Results as text the model reads: strings as they are, the rest as JSON. */
|
|
9
|
+
const asText = (value) => (typeof value === 'string' ? value : JSON.stringify(value, null, 1));
|
|
10
|
+
/** Registers the site's tools on an MCP server; each call runs as the person behind `call`. */
|
|
11
|
+
export function registerTools(server, tools) {
|
|
12
|
+
for (const t of tools) {
|
|
13
|
+
server.registerTool(t.name, {
|
|
14
|
+
title: t.title,
|
|
15
|
+
description: t.description,
|
|
16
|
+
inputSchema: t.input,
|
|
17
|
+
annotations: { readOnlyHint: !!t.readOnly, destructiveHint: !t.readOnly && /remove|unpublish/.test(t.name) },
|
|
18
|
+
}, async (input) => {
|
|
19
|
+
try {
|
|
20
|
+
return { content: [{ type: 'text', text: asText(await t.run(input)) }] };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!(error instanceof ToolError))
|
|
24
|
+
console.error(error);
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** A fresh MCP server for one request (stateless): the tools act as the person who sent it. */
|
|
32
|
+
export function createMcpServer(call, version) {
|
|
33
|
+
const server = new McpServer({ name: 'materialize', title: 'Materialize', version }, { instructions: INSTRUCTIONS });
|
|
34
|
+
registerTools(server, siteTools(call));
|
|
35
|
+
return server;
|
|
36
|
+
}
|
package/src/media.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { MediaAsset } from '@materializecms/db';
|
|
2
|
+
export declare const MAX_UPLOAD: number;
|
|
3
|
+
/** Widths served to the site; requests snap to the nearest one so the cache stays small. */
|
|
4
|
+
export declare const WIDTHS: number[];
|
|
5
|
+
export declare const OUTPUTS: {
|
|
6
|
+
readonly avif: 'image/avif';
|
|
7
|
+
readonly webp: 'image/webp';
|
|
8
|
+
readonly jpg: 'image/jpeg';
|
|
9
|
+
readonly png: 'image/png';
|
|
10
|
+
};
|
|
11
|
+
export type Output = keyof typeof OUTPUTS;
|
|
12
|
+
export declare class MediaError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export declare const isMediaKey: (key: string) => boolean;
|
|
15
|
+
export declare const mediaUrl: (asset: Pick<MediaAsset, 'key'>) => string;
|
|
16
|
+
/** Files on disk: originals in `dir`, resized versions in `dir/.cache/<key>/`. */
|
|
17
|
+
export declare function createMediaFiles(dir: string): {
|
|
18
|
+
/**
|
|
19
|
+
* Checks and stores an upload. Photos are turned upright and lose their metadata
|
|
20
|
+
* (camera details, GPS position), so publishing a picture never publishes where it was taken.
|
|
21
|
+
*/
|
|
22
|
+
save(name: string, input: Buffer): Promise<{
|
|
23
|
+
key: string;
|
|
24
|
+
mime: string;
|
|
25
|
+
size: number;
|
|
26
|
+
width: null;
|
|
27
|
+
height: null;
|
|
28
|
+
} | {
|
|
29
|
+
key: string;
|
|
30
|
+
mime: string;
|
|
31
|
+
size: number;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}>;
|
|
35
|
+
remove(key: string): Promise<void>;
|
|
36
|
+
/** The original file, or a resized copy made on first request and kept for next time. */
|
|
37
|
+
open(key: string, variant?: {
|
|
38
|
+
width: number;
|
|
39
|
+
format: Output;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
path: string;
|
|
42
|
+
type: string;
|
|
43
|
+
} | null>;
|
|
44
|
+
};
|
|
45
|
+
export declare function fileBody(path: string): ReadableStream;
|
|
46
|
+
export type MediaFiles = ReturnType<typeof createMediaFiles>;
|
package/src/media.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { slugify } from '@materializecms/core';
|
|
7
|
+
import sharp, {} from 'sharp';
|
|
8
|
+
/** Formats people can upload, by what the file really is (not by its name). */
|
|
9
|
+
const FORMATS = { jpeg: 'jpg', png: 'png', webp: 'webp', gif: 'gif', heif: 'avif' };
|
|
10
|
+
export const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
11
|
+
/** Widths served to the site; requests snap to the nearest one so the cache stays small. */
|
|
12
|
+
export const WIDTHS = [320, 640, 960, 1280, 1600, 1920, 2560];
|
|
13
|
+
export const OUTPUTS = { avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', png: 'image/png' };
|
|
14
|
+
const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\.(jpg|png|webp|gif|avif|glb)$/;
|
|
15
|
+
const TYPES = {
|
|
16
|
+
jpg: 'image/jpeg',
|
|
17
|
+
png: 'image/png',
|
|
18
|
+
webp: 'image/webp',
|
|
19
|
+
gif: 'image/gif',
|
|
20
|
+
avif: 'image/avif',
|
|
21
|
+
glb: 'model/gltf-binary',
|
|
22
|
+
};
|
|
23
|
+
/** A binary glTF 2 file: "glTF", version 2, then its declared length. */
|
|
24
|
+
const isGlb = (b) => b.length > 20 && b.toString('latin1', 0, 4) === 'glTF' && b.readUInt32LE(4) === 2;
|
|
25
|
+
export class MediaError extends Error {
|
|
26
|
+
}
|
|
27
|
+
export const isMediaKey = (key) => KEY.test(key);
|
|
28
|
+
export const mediaUrl = (asset) => `/media/${asset.key}`;
|
|
29
|
+
/** Files on disk: originals in `dir`, resized versions in `dir/.cache/<key>/`. */
|
|
30
|
+
export function createMediaFiles(dir) {
|
|
31
|
+
const root = resolve(dir);
|
|
32
|
+
const pending = new Map();
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Checks and stores an upload. Photos are turned upright and lose their metadata
|
|
36
|
+
* (camera details, GPS position), so publishing a picture never publishes where it was taken.
|
|
37
|
+
*/
|
|
38
|
+
async save(name, input) {
|
|
39
|
+
// 3D models for WebGL scenes, kept as they are.
|
|
40
|
+
if (isGlb(input)) {
|
|
41
|
+
const base = slugify(name.replace(/\.[^.]+$/, ''), 48) || 'modello';
|
|
42
|
+
const key = `${base}-${randomBytes(4).toString('hex')}.glb`;
|
|
43
|
+
await mkdir(root, { recursive: true });
|
|
44
|
+
await writeFile(join(root, key), input);
|
|
45
|
+
return { key, mime: TYPES.glb, size: input.length, width: null, height: null };
|
|
46
|
+
}
|
|
47
|
+
let meta;
|
|
48
|
+
try {
|
|
49
|
+
meta = await sharp(input, { animated: true }).metadata();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new MediaError('Il file non è un’immagine leggibile né un modello 3D (.glb)');
|
|
53
|
+
}
|
|
54
|
+
const format = meta.format;
|
|
55
|
+
if (!(format in FORMATS))
|
|
56
|
+
throw new MediaError('Formato non supportato: usa JPG, PNG, WebP, AVIF o GIF');
|
|
57
|
+
const ext = FORMATS[format];
|
|
58
|
+
let output = input;
|
|
59
|
+
let width = meta.width;
|
|
60
|
+
let height = meta.autoOrient?.height ?? meta.height;
|
|
61
|
+
if (format === 'jpeg' || format === 'png' || format === 'webp') {
|
|
62
|
+
const { data, info } = await sharp(input)
|
|
63
|
+
.rotate()
|
|
64
|
+
.toFormat(format, format === 'png' ? { compressionLevel: 9 } : { quality: 90 })
|
|
65
|
+
.toBuffer({ resolveWithObject: true });
|
|
66
|
+
output = data;
|
|
67
|
+
width = info.width;
|
|
68
|
+
height = info.height;
|
|
69
|
+
}
|
|
70
|
+
const base = slugify(name.replace(/\.[^.]+$/, ''), 48) || 'immagine';
|
|
71
|
+
const key = `${base}-${randomBytes(4).toString('hex')}.${ext}`;
|
|
72
|
+
await mkdir(root, { recursive: true });
|
|
73
|
+
await writeFile(join(root, key), output);
|
|
74
|
+
return { key, mime: TYPES[ext], size: output.length, width: width ?? null, height: height ?? null };
|
|
75
|
+
},
|
|
76
|
+
async remove(key) {
|
|
77
|
+
if (!isMediaKey(key))
|
|
78
|
+
return;
|
|
79
|
+
await rm(join(root, key), { force: true });
|
|
80
|
+
await rm(join(root, '.cache', key), { recursive: true, force: true });
|
|
81
|
+
},
|
|
82
|
+
/** The original file, or a resized copy made on first request and kept for next time. */
|
|
83
|
+
async open(key, variant) {
|
|
84
|
+
if (!isMediaKey(key))
|
|
85
|
+
return null;
|
|
86
|
+
const original = join(root, key);
|
|
87
|
+
if (!(await stat(original).catch(() => null))?.isFile())
|
|
88
|
+
return null;
|
|
89
|
+
if (!variant || key.endsWith('.gif') || key.endsWith('.glb')) {
|
|
90
|
+
return { path: original, type: TYPES[key.split('.').pop()] };
|
|
91
|
+
}
|
|
92
|
+
const width = WIDTHS.find((w) => w >= variant.width) ?? WIDTHS[WIDTHS.length - 1];
|
|
93
|
+
const path = join(root, '.cache', key, `${width}.${variant.format}`);
|
|
94
|
+
if (!(await stat(path).catch(() => null))) {
|
|
95
|
+
let job = pending.get(path);
|
|
96
|
+
if (!job) {
|
|
97
|
+
job = render(original, path, width, variant.format).finally(() => pending.delete(path));
|
|
98
|
+
pending.set(path, job);
|
|
99
|
+
}
|
|
100
|
+
await job;
|
|
101
|
+
}
|
|
102
|
+
return { path, type: OUTPUTS[variant.format] };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async function render(source, target, width, format) {
|
|
107
|
+
await mkdir(join(target, '..'), { recursive: true });
|
|
108
|
+
const image = sharp(await readFile(source))
|
|
109
|
+
.rotate()
|
|
110
|
+
.resize({ width, withoutEnlargement: true });
|
|
111
|
+
const encoded = format === 'avif'
|
|
112
|
+
? image.avif({ quality: 55, effort: 4 })
|
|
113
|
+
: format === 'webp'
|
|
114
|
+
? image.webp({ quality: 78 })
|
|
115
|
+
: format === 'png'
|
|
116
|
+
? image.png({ compressionLevel: 9 })
|
|
117
|
+
: image.jpeg({ quality: 80, mozjpeg: true });
|
|
118
|
+
// Written aside and renamed, so a half-written file is never served.
|
|
119
|
+
const tmp = `${target}.${randomBytes(4).toString('hex')}.tmp`;
|
|
120
|
+
await writeFile(tmp, await encoded.toBuffer());
|
|
121
|
+
await rename(tmp, target);
|
|
122
|
+
return target;
|
|
123
|
+
}
|
|
124
|
+
export function fileBody(path) {
|
|
125
|
+
return Readable.toWeb(createReadStream(path));
|
|
126
|
+
}
|
package/src/password.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const PARAMS = { N: 2 ** 15, r: 8, p: 1, keylen: 32 };
|
|
3
|
+
function derive(password, salt, o) {
|
|
4
|
+
const options = { N: o.N, r: o.r, p: o.p, maxmem: 128 * o.N * o.r * 2 };
|
|
5
|
+
return new Promise((resolve, reject) => scrypt(password.normalize('NFKC'), salt, o.keylen, options, (err, key) => (err ? reject(err) : resolve(key))));
|
|
6
|
+
}
|
|
7
|
+
/** scrypt from the Node standard library: no native dependency to install. */
|
|
8
|
+
export async function hashPassword(password) {
|
|
9
|
+
const salt = randomBytes(16);
|
|
10
|
+
const key = await derive(password, salt, PARAMS);
|
|
11
|
+
return ['scrypt', PARAMS.N, PARAMS.r, PARAMS.p, salt.toString('base64url'), key.toString('base64url')].join('$');
|
|
12
|
+
}
|
|
13
|
+
export async function verifyPassword(password, stored) {
|
|
14
|
+
const [scheme, n, r, p, salt, hash] = stored.split('$');
|
|
15
|
+
if (scheme !== 'scrypt' || !salt || !hash)
|
|
16
|
+
return false;
|
|
17
|
+
const expected = Buffer.from(hash, 'base64url');
|
|
18
|
+
const key = await derive(password, Buffer.from(salt, 'base64url'), {
|
|
19
|
+
N: Number(n),
|
|
20
|
+
r: Number(r),
|
|
21
|
+
p: Number(p),
|
|
22
|
+
keylen: expected.length,
|
|
23
|
+
});
|
|
24
|
+
return key.length === expected.length && timingSafeEqual(key, expected);
|
|
25
|
+
}
|
package/src/remote.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Raised when a remote file cannot be fetched: the message says why, in words. */
|
|
2
|
+
export declare class RemoteError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface RemoteResponse {
|
|
5
|
+
status: number;
|
|
6
|
+
headers: Headers;
|
|
7
|
+
bytes: Buffer;
|
|
8
|
+
type: string;
|
|
9
|
+
/** The file name at the final address (after redirects). */
|
|
10
|
+
name: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fetches from the web: http(s) only, redirects checked one by one, at most `max` bytes. Addresses
|
|
15
|
+
* on this machine or its network are refused, unless `allowPrivate` (an administrator importing
|
|
16
|
+
* from a WordPress on their own network). Headers are sent to the first host only.
|
|
17
|
+
*/
|
|
18
|
+
export declare function remoteFetch(url: string, options: {
|
|
19
|
+
max: number;
|
|
20
|
+
headers?: Record<string, string>;
|
|
21
|
+
allowPrivate?: boolean;
|
|
22
|
+
timeout?: number;
|
|
23
|
+
}): Promise<RemoteResponse>;
|
|
24
|
+
/**
|
|
25
|
+
* Downloads a public file (an image an AI found on the web): http(s) only, never to an address on
|
|
26
|
+
* this machine or its network, redirects checked one by one, at most `max` bytes.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchPublicFile(url: string, max: number): Promise<{
|
|
29
|
+
bytes: Buffer;
|
|
30
|
+
type: string;
|
|
31
|
+
name: string;
|
|
32
|
+
}>;
|
package/src/remote.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
/** Raised when a remote file cannot be fetched: the message says why, in words. */
|
|
4
|
+
export class RemoteError extends Error {
|
|
5
|
+
}
|
|
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');
|
|
13
|
+
}
|
|
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);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fetches from the web: http(s) only, redirects checked one by one, at most `max` bytes. Addresses
|
|
26
|
+
* 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.
|
|
28
|
+
*/
|
|
29
|
+
export async function remoteFetch(url, options) {
|
|
30
|
+
let current = new URL(url);
|
|
31
|
+
const origin = current.origin;
|
|
32
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
33
|
+
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');
|
|
40
|
+
}
|
|
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');
|
|
48
|
+
if (!to)
|
|
49
|
+
throw new RemoteError(`Redirect without a destination (${res.status})`);
|
|
50
|
+
current = new URL(to, current);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
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');
|
|
56
|
+
const chunks = [];
|
|
57
|
+
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));
|
|
63
|
+
}
|
|
64
|
+
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 };
|
|
66
|
+
}
|
|
67
|
+
throw new RemoteError('Too many redirects');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Downloads a public file (an image an AI found on the web): http(s) only, never to an address on
|
|
71
|
+
* this machine or its network, redirects checked one by one, at most `max` bytes.
|
|
72
|
+
*/
|
|
73
|
+
export async function fetchPublicFile(url, max) {
|
|
74
|
+
const res = await remoteFetch(url, { max });
|
|
75
|
+
if (res.status < 200 || res.status >= 300)
|
|
76
|
+
throw new RemoteError(`The server answered ${res.status}`);
|
|
77
|
+
return { bytes: res.bytes, type: res.type, name: res.name };
|
|
78
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { MaterializeConfig } from '@materializecms/core';
|
|
2
|
+
/** Page served at "/" until the site renderer ships: confirms the server works and links to the admin. */
|
|
3
|
+
export declare function statusPage(config: MaterializeConfig, version: string, needsSetup: boolean): Promise<import("hono/utils/html").HtmlEscapedString> | import("hono/utils/html").HtmlEscapedString;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { html } from 'hono/html';
|
|
2
|
+
/** Page served at "/" until the site renderer ships: confirms the server works and links to the admin. */
|
|
3
|
+
export function statusPage(config, version, needsSetup) {
|
|
4
|
+
const rows = config.collections.map((c) => html `<li><span>${c.label}</span><code>/api/content/${c.name}</code></li>`);
|
|
5
|
+
return html `<!doctype html>
|
|
6
|
+
<html lang="it">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8" />
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
10
|
+
<title>${config.site.name} · Materialize</title>
|
|
11
|
+
<style>
|
|
12
|
+
:root { color-scheme: light dark; --bg: #fafafa; --fg: #0a0a0b; --muted: #5e5e66; --line: #e6e6ea; --accent: #3d5afe; }
|
|
13
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0b0b0c; --fg: #ededef; --muted: #a1a1aa; --line: #232327; --accent: #7c93ff; } }
|
|
14
|
+
* { box-sizing: border-box; }
|
|
15
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px 16px; background: var(--bg); color: var(--fg); font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', sans-serif; }
|
|
16
|
+
main { width: 100%; max-width: 560px; }
|
|
17
|
+
.mark { width: 36px; height: 36px; border-radius: 9px; background: var(--accent); display: grid; place-items: center; color: #fff; font-weight: 700; }
|
|
18
|
+
h1 { font-size: 22px; margin: 20px 0 4px; letter-spacing: -0.01em; }
|
|
19
|
+
p { margin: 0 0 16px; color: var(--muted); }
|
|
20
|
+
ul { list-style: none; padding: 0; margin: 20px 0; border-top: 1px solid var(--line); }
|
|
21
|
+
li { display: flex; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
|
22
|
+
code { font: 13px ui-monospace, 'SF Mono', monospace; color: var(--muted); }
|
|
23
|
+
.note { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--line); font-size: 14px; }
|
|
24
|
+
.button { display: inline-flex; align-items: center; height: 40px; padding: 0 18px; border-radius: 10px; background: var(--fg); color: var(--bg); font-weight: 600; text-decoration: none; }
|
|
25
|
+
</style>
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<main>
|
|
29
|
+
<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
|
+
<ul>
|
|
33
|
+
${rows}
|
|
34
|
+
</ul>
|
|
35
|
+
<a class="button" href="/admin/">${needsSetup ? 'Configura il pannello' : 'Apri il pannello'}</a>
|
|
36
|
+
</main>
|
|
37
|
+
</body>
|
|
38
|
+
</html>`;
|
|
39
|
+
}
|
package/src/tools.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type PageNode } from '@materializecms/core/page';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* What an AI can do with the site, shared by the MCP server and the editor's assistant. Every tool
|
|
5
|
+
* goes through the HTTP API as the person who asked (their token or their session), so it can do
|
|
6
|
+
* exactly what they could do in the panel: the same checks, the same Content mode limits.
|
|
7
|
+
*/
|
|
8
|
+
/** Calls the site's own API as the person using the tools. */
|
|
9
|
+
export type ApiCall = <T = unknown>(method: string, path: string, body?: unknown) => Promise<T>;
|
|
10
|
+
/** A page being edited: loaded and saved through the API, or held by the editor (the assistant). */
|
|
11
|
+
export interface PageSource {
|
|
12
|
+
load: () => Promise<PageNode[]>;
|
|
13
|
+
save: (nodes: PageNode[]) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface SiteTool {
|
|
16
|
+
name: string;
|
|
17
|
+
/** A short title for clients that show one. */
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
input: z.ZodObject;
|
|
21
|
+
/** Only reads: clients may run it without asking. */
|
|
22
|
+
readOnly?: boolean;
|
|
23
|
+
run: (input: never) => Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** An answer the model can act on: what went wrong, in words. */
|
|
26
|
+
export declare class ToolError extends Error {
|
|
27
|
+
}
|
|
28
|
+
type FieldInfo = {
|
|
29
|
+
name: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
label?: string;
|
|
32
|
+
};
|
|
33
|
+
type CollectionInfo = {
|
|
34
|
+
name: string;
|
|
35
|
+
label?: string;
|
|
36
|
+
singleton?: boolean;
|
|
37
|
+
fields: FieldInfo[];
|
|
38
|
+
titleField?: string;
|
|
39
|
+
slugField?: string;
|
|
40
|
+
};
|
|
41
|
+
type Schema = {
|
|
42
|
+
site: {
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
locales: {
|
|
46
|
+
default: string;
|
|
47
|
+
available: string[];
|
|
48
|
+
};
|
|
49
|
+
collections: CollectionInfo[];
|
|
50
|
+
};
|
|
51
|
+
/** How the page model works, for a model that is about to edit one. */
|
|
52
|
+
export declare const PAGE_GUIDE = "Pages are trees of nodes. The top level holds sections (type \"frame\"); sections hold frames, texts, images, buttons, icons, videos, scenes and form inputs.\nEvery node has an \"id\" (keep existing ids; new nodes may omit it, one is assigned) and a \"type\".\n\nCommon properties:\n- Layout of a frame: layout \"col\" | \"row\" | \"grid\", cols (grid columns), gap (px), align \"start\"|\"center\"|\"end\"|\"stretch\", justify \"start\"|\"center\"|\"end\"|\"between\", wrap.\n- Size: width \"fill\"|\"hug\"|\"fixed\" with fixedW, maxW, h (px or \"60vh\"), minH (percent of screen height, sections), padX/padY (px), size \"xs\"|\"s\"|\"m\"|\"l\" (a section's vertical padding).\n- Look: bg (theme token or CSS color), scheme \"default\"|\"surface\"|\"inverse\"|\"accent\" (a section's color scheme), radius (px | \"btn\" | \"card\"), border, shadow \"none\"|\"sm\"|\"lg\", gradient, bgImage {src, alt}, bgDim 0-1.\n- Text (\"text\"): text (plain) or runs [{t, b?, i?, href?}], style \"display\"|\"h1\"|\"h2\"|\"h3\"|\"body\"|\"small\"|\"eyebrow\"|\"logo\"|\"button\", tag \"h1\"..\"h4\"|\"p\" (HTML element, defaults from style), color, talign \"left\"|\"center\"|\"right\", fontSize, weight, upper, italic.\n- Button (\"button\"): text, href, variant \"primary\"|\"secondary\"|\"ghost\", newTab.\n- Image (\"image\"): src (a URL from the media library, e.g. /media/photo.jpg), alt, ratio (\"16/9\", \"1/1\"\u2026), fit \"cover\"|\"contain\".\n- Icon (\"icon\"): icon \"lucide:arrow-right\" style names.\n- Motion: anim \"fade\"|\"up\"|\"down\"|\"left\"|\"right\"|\"zoom\"|\"blur\"|\"reveal\"|\"pop\", animTrig \"load\"|\"scroll\", hover \"lift\"|\"grow\"|\"dim\"|\"zoomimg\".\n- Tablet and mobile changes go in over: {tablet: {...}, mobile: {...}} (only the properties that differ).\nTheme color tokens: bg, surface, text, muted, accent, onAccent, inverse, onInverse, border, line.\nPrefer adding ready-made sections from the library (add_section) and then changing their texts, images and colors: they are designed and responsive. Write content in the site's language.";
|
|
53
|
+
/** One line per node, indented: what the model reads before editing. */
|
|
54
|
+
export declare function outline(nodes: PageNode[]): string;
|
|
55
|
+
declare const operation: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
56
|
+
op: z.ZodLiteral<"add_section">;
|
|
57
|
+
key: z.ZodString;
|
|
58
|
+
index: z.ZodOptional<z.ZodNumber>;
|
|
59
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
60
|
+
op: z.ZodLiteral<"insert">;
|
|
61
|
+
parentId: z.ZodNullable<z.ZodString>;
|
|
62
|
+
index: z.ZodOptional<z.ZodNumber>;
|
|
63
|
+
node: z.ZodObject<{
|
|
64
|
+
type: z.ZodString;
|
|
65
|
+
id: z.ZodOptional<z.ZodString>;
|
|
66
|
+
}, z.core.$loose>;
|
|
67
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
68
|
+
op: z.ZodLiteral<"update">;
|
|
69
|
+
nodeId: z.ZodString;
|
|
70
|
+
props: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
71
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
72
|
+
op: z.ZodLiteral<"replace">;
|
|
73
|
+
nodeId: z.ZodString;
|
|
74
|
+
node: z.ZodObject<{
|
|
75
|
+
type: z.ZodString;
|
|
76
|
+
id: z.ZodOptional<z.ZodString>;
|
|
77
|
+
}, z.core.$loose>;
|
|
78
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
79
|
+
op: z.ZodLiteral<"remove">;
|
|
80
|
+
nodeId: z.ZodString;
|
|
81
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
82
|
+
op: z.ZodLiteral<"move">;
|
|
83
|
+
nodeId: z.ZodString;
|
|
84
|
+
parentId: z.ZodNullable<z.ZodString>;
|
|
85
|
+
index: z.ZodNumber;
|
|
86
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
87
|
+
op: z.ZodLiteral<"duplicate">;
|
|
88
|
+
nodeId: z.ZodString;
|
|
89
|
+
}, z.core.$strip>], "op">;
|
|
90
|
+
export type PageOperation = z.infer<typeof operation>;
|
|
91
|
+
/** Applies edits to a page tree; stops at the first that cannot apply, saying which and why. */
|
|
92
|
+
export declare function applyOperations(nodes: PageNode[], ops: PageOperation[], brand: string): PageNode[];
|
|
93
|
+
/** The page tools: read the outline and a node, and edit. */
|
|
94
|
+
export declare function pageTools(options: {
|
|
95
|
+
/** The page to work on: by document id (MCP), or the one open in the editor (no id). */
|
|
96
|
+
source: (id: string | undefined) => Promise<PageSource>;
|
|
97
|
+
withId: boolean;
|
|
98
|
+
brand: () => Promise<string>;
|
|
99
|
+
}): SiteTool[];
|
|
100
|
+
/** Tools for the whole site: collections and entries, pages by id, theme, media. */
|
|
101
|
+
export declare function siteTools(call: ApiCall): SiteTool[];
|
|
102
|
+
/**
|
|
103
|
+
* The editor's assistant: the site tools that make sense next to the open page (no publishing:
|
|
104
|
+
* the editor has its own button), and page tools working on the page as the editor holds it.
|
|
105
|
+
*/
|
|
106
|
+
export declare function assistantTools(call: ApiCall, page: {
|
|
107
|
+
nodes: PageNode[];
|
|
108
|
+
}, brand: string): SiteTool[];
|
|
109
|
+
/** Everything but the page editing: shared with the editor's assistant, which edits the open page itself. */
|
|
110
|
+
export declare function contentTools(call: ApiCall, getSchema: () => Promise<Schema>): SiteTool[];
|
|
111
|
+
/**
|
|
112
|
+
* Calls the API as whoever sent `auth` (cookie or bearer header): in process through the Hono app's
|
|
113
|
+
* `fetch`, or over the network to a running server at `origin` (`materialize mcp`).
|
|
114
|
+
*/
|
|
115
|
+
export declare function apiCaller(fetch: (req: Request) => Response | Promise<Response>, auth: Record<string, string>, origin?: string): ApiCall;
|
|
116
|
+
export {};
|