@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/src/tools.js ADDED
@@ -0,0 +1,474 @@
1
+ import { buildSection, LIBRARY } from '@materializecms/core/library';
2
+ import { cloneNode, collapseComponents, collapseGlobals, expandComponents, expandGlobals, findNode, insertNode, newId, plainText, removeNode, updateNode, } from '@materializecms/core/page';
3
+ import { z } from 'zod';
4
+ /** An answer the model can act on: what went wrong, in words. */
5
+ export class ToolError extends Error {
6
+ }
7
+ const tool = (def) => def;
8
+ /** How the page model works, for a model that is about to edit one. */
9
+ export 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.
10
+ Every node has an "id" (keep existing ids; new nodes may omit it, one is assigned) and a "type".
11
+
12
+ Common properties:
13
+ - Layout of a frame: layout "col" | "row" | "grid", cols (grid columns), gap (px), align "start"|"center"|"end"|"stretch", justify "start"|"center"|"end"|"between", wrap.
14
+ - 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).
15
+ - 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.
16
+ - 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.
17
+ - Button ("button"): text, href, variant "primary"|"secondary"|"ghost", newTab.
18
+ - Image ("image"): src (a URL from the media library, e.g. /media/photo.jpg), alt, ratio ("16/9", "1/1"…), fit "cover"|"contain".
19
+ - Icon ("icon"): icon "lucide:arrow-right" style names.
20
+ - Motion: anim "fade"|"up"|"down"|"left"|"right"|"zoom"|"blur"|"reveal"|"pop", animTrig "load"|"scroll", hover "lift"|"grow"|"dim"|"zoomimg".
21
+ - Tablet and mobile changes go in over: {tablet: {...}, mobile: {...}} (only the properties that differ).
22
+ Theme color tokens: bg, surface, text, muted, accent, onAccent, inverse, onInverse, border, line.
23
+ Prefer 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.`;
24
+ /** One line per node, indented: what the model reads before editing. */
25
+ export function outline(nodes) {
26
+ const lines = [];
27
+ const go = (list, depth) => {
28
+ for (const n of list) {
29
+ const bits = [n.type];
30
+ if (n.name)
31
+ bits.push(`"${n.name}"`);
32
+ if (n.global)
33
+ bits.push('(shared section: changes apply to every page using it)');
34
+ if (n.component)
35
+ bits.push('(component copy)');
36
+ if (n.type === 'frame' && n.layout)
37
+ bits.push(`layout=${n.layout}${n.cols ? ` cols=${n.cols}` : ''}`);
38
+ if (n.style)
39
+ bits.push(`style=${n.style}`);
40
+ const text = plainText(n);
41
+ if (text)
42
+ bits.push(JSON.stringify(text.length > 90 ? `${text.slice(0, 90)}…` : text));
43
+ if (n.src)
44
+ bits.push(`src=${n.src}`);
45
+ if (n.href)
46
+ bits.push(`href=${n.href}`);
47
+ if (n.bind)
48
+ bits.push(`list of ${n.bind.collection}`);
49
+ if (n.field)
50
+ bits.push(`shows field ${n.field}`);
51
+ if (n.hidden)
52
+ bits.push('hidden');
53
+ lines.push(`${' '.repeat(depth)}- [${n.id}] ${bits.join(' ')}`);
54
+ if (n.children?.length)
55
+ go(n.children, depth + 1);
56
+ }
57
+ };
58
+ go(nodes, 0);
59
+ return lines.length ? lines.join('\n') : '(empty page)';
60
+ }
61
+ /** New nodes get ids where they lack one; the rest stay as they are. */
62
+ function withIds(n) {
63
+ return { ...n, id: typeof n.id === 'string' && n.id ? n.id : newId(), children: n.children?.map(withIds) };
64
+ }
65
+ const nodeShape = z.looseObject({ type: z.string(), id: z.string().optional() });
66
+ const operation = z.discriminatedUnion('op', [
67
+ z.object({
68
+ op: z.literal('add_section'),
69
+ key: z.string().describe('Library section, e.g. "hero.split" (see list_library)'),
70
+ index: z.number().int().optional().describe('Position among the sections; default: at the end'),
71
+ }),
72
+ z.object({
73
+ op: z.literal('insert'),
74
+ parentId: z.string().nullable().describe('Container to insert into; null for a new section on the page'),
75
+ index: z.number().int().optional().describe('Position among its children; default: at the end'),
76
+ node: nodeShape.describe('The new node, with its children'),
77
+ }),
78
+ z.object({
79
+ op: z.literal('update'),
80
+ nodeId: z.string(),
81
+ props: z.record(z.string(), z.unknown()).describe('Properties to set; null removes one'),
82
+ }),
83
+ z.object({ op: z.literal('replace'), nodeId: z.string(), node: nodeShape }),
84
+ z.object({ op: z.literal('remove'), nodeId: z.string() }),
85
+ z.object({
86
+ op: z.literal('move'),
87
+ nodeId: z.string(),
88
+ parentId: z.string().nullable(),
89
+ index: z.number().int(),
90
+ }),
91
+ z.object({ op: z.literal('duplicate'), nodeId: z.string() }),
92
+ ]);
93
+ /** Applies edits to a page tree; stops at the first that cannot apply, saying which and why. */
94
+ export function applyOperations(nodes, ops, brand) {
95
+ let tree = nodes;
96
+ for (const [i, op] of ops.entries()) {
97
+ const fail = (why) => {
98
+ throw new ToolError(`Operation ${i + 1} (${op.op}): ${why}. Nothing was changed.`);
99
+ };
100
+ const need = (id) => findNode(tree, id) ?? fail(`there is no node "${id}"`);
101
+ switch (op.op) {
102
+ case 'add_section': {
103
+ if (!LIBRARY.some((c) => c.variants.some((v) => v.key === op.key)))
104
+ fail(`no library section "${op.key}"`);
105
+ tree = insertNode(tree, null, op.index ?? tree.length, buildSection(op.key, brand));
106
+ break;
107
+ }
108
+ case 'insert': {
109
+ if (op.parentId)
110
+ need(op.parentId);
111
+ const parent = op.parentId ? findNode(tree, op.parentId)?.node : null;
112
+ if (parent && parent.type !== 'frame')
113
+ fail('only frames can hold other nodes');
114
+ const count = parent ? (parent.children?.length ?? 0) : tree.length;
115
+ tree = insertNode(tree, op.parentId, op.index ?? count, withIds(op.node));
116
+ break;
117
+ }
118
+ case 'update': {
119
+ need(op.nodeId);
120
+ tree = updateNode(tree, op.nodeId, (n) => {
121
+ const next = { ...n };
122
+ for (const [k, v] of Object.entries(op.props)) {
123
+ if (k === 'id' || k === 'children')
124
+ continue;
125
+ if (v === null)
126
+ delete next[k];
127
+ else
128
+ next[k] = v;
129
+ }
130
+ // Plain text replaces formatted text.
131
+ if ('text' in op.props && !('runs' in op.props))
132
+ delete next.runs;
133
+ return next;
134
+ });
135
+ break;
136
+ }
137
+ case 'replace': {
138
+ need(op.nodeId);
139
+ tree = updateNode(tree, op.nodeId, () => withIds({ ...op.node, id: op.nodeId }));
140
+ break;
141
+ }
142
+ case 'remove':
143
+ need(op.nodeId);
144
+ tree = removeNode(tree, op.nodeId);
145
+ break;
146
+ case 'move': {
147
+ const found = need(op.nodeId);
148
+ if (op.parentId && findNode([found.node], op.parentId))
149
+ fail('a node cannot go inside itself');
150
+ if (op.parentId)
151
+ need(op.parentId);
152
+ tree = insertNode(removeNode(tree, op.nodeId), op.parentId, op.index, found.node);
153
+ break;
154
+ }
155
+ case 'duplicate': {
156
+ const found = need(op.nodeId);
157
+ tree = insertNode(tree, found.parent?.id ?? null, found.index + 1, cloneNode(found.node));
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ return tree;
163
+ }
164
+ /** The page tools: read the outline and a node, and edit. */
165
+ export function pageTools(options) {
166
+ const id = options.withId
167
+ ? { id: z.string().describe('The page document id (from list_entries)') }
168
+ : {};
169
+ const idOf = (input) => input.id;
170
+ return [
171
+ tool({
172
+ name: 'get_page',
173
+ title: 'Leggo la pagina',
174
+ 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.',
175
+ input: z.object({ ...id }),
176
+ readOnly: true,
177
+ run: async (input) => outline(await (await options.source(idOf(input))).load()),
178
+ }),
179
+ tool({
180
+ name: 'get_node',
181
+ title: 'Leggo un elemento',
182
+ description: 'One node with all its properties and children, as JSON: to see its exact design before changing it.',
183
+ input: z.object({ ...id, nodeId: z.string() }),
184
+ readOnly: true,
185
+ run: async (input) => {
186
+ const found = findNode(await (await options.source(idOf(input))).load(), input.nodeId);
187
+ if (!found)
188
+ throw new ToolError(`There is no node "${input.nodeId}" on this page`);
189
+ return found.node;
190
+ },
191
+ }),
192
+ tool({
193
+ name: 'edit_page',
194
+ title: 'Modifico la pagina',
195
+ 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}`,
196
+ input: z.object({ ...id, operations: z.array(operation).min(1).max(60) }),
197
+ run: async (input) => {
198
+ const page = await options.source(idOf(input));
199
+ const next = applyOperations(await page.load(), input.operations, await options.brand());
200
+ await page.save(next);
201
+ return outline(next);
202
+ },
203
+ }),
204
+ ];
205
+ }
206
+ /** Tools for the whole site: collections and entries, pages by id, theme, media. */
207
+ export function siteTools(call) {
208
+ let schema = null;
209
+ const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
210
+ const pageField = async (doc) => {
211
+ const def = (await getSchema()).collections.find((c) => c.name === doc.collection);
212
+ const field = def?.fields.find((f) => f.kind === 'page');
213
+ if (!field)
214
+ throw new ToolError(`"${doc.title}" is not a page built on the canvas`);
215
+ return field.name;
216
+ };
217
+ // A page by id, with shared sections and components expanded as in the editor; saving folds them back.
218
+ const source = async (id) => {
219
+ if (!id)
220
+ throw new ToolError('Say which page: its id');
221
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(id)}`);
222
+ const name = await pageField(doc);
223
+ const { globals } = await call('GET', '/api/globals');
224
+ const { components } = await call('GET', '/api/components');
225
+ return {
226
+ load: async () => expandComponents(expandGlobals(Array.isArray(doc.data[name]) ? doc.data[name] : [], globals), components),
227
+ save: async (nodes) => {
228
+ const withRefs = collapseComponents(nodes, components);
229
+ if (JSON.stringify(withRefs.components) !== JSON.stringify(components))
230
+ await call('PUT', '/api/components', { components: withRefs.components });
231
+ const collapsed = collapseGlobals(withRefs.nodes);
232
+ const merged = { ...globals, ...collapsed.globals };
233
+ if (JSON.stringify(merged) !== JSON.stringify(globals))
234
+ await call('PUT', '/api/globals', { globals: merged });
235
+ await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
236
+ data: { ...doc.data, [name]: collapsed.nodes },
237
+ });
238
+ },
239
+ };
240
+ };
241
+ return [
242
+ ...contentTools(call, getSchema),
243
+ ...pageTools({ source, withId: true, brand: async () => (await getSchema()).site.name }),
244
+ ];
245
+ }
246
+ /**
247
+ * The editor's assistant: the site tools that make sense next to the open page (no publishing:
248
+ * the editor has its own button), and page tools working on the page as the editor holds it.
249
+ */
250
+ export function assistantTools(call, page, brand) {
251
+ let schema = null;
252
+ const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
253
+ const source = async () => ({
254
+ load: async () => page.nodes,
255
+ save: async (nodes) => {
256
+ page.nodes = nodes;
257
+ },
258
+ });
259
+ return [
260
+ ...contentTools(call, getSchema).filter((t) => t.name !== 'publish_entry' && t.name !== 'unpublish_entry'),
261
+ ...pageTools({ source, withId: false, brand: async () => brand }),
262
+ ];
263
+ }
264
+ /** Everything but the page editing: shared with the editor's assistant, which edits the open page itself. */
265
+ export function contentTools(call, getSchema) {
266
+ return [
267
+ tool({
268
+ name: 'get_site',
269
+ title: 'Guardo il sito',
270
+ description: 'The site: its name, languages and collections (pages, articles, projects…) with their fields. Start here.',
271
+ input: z.object({}),
272
+ readOnly: true,
273
+ run: async () => {
274
+ const s = await getSchema();
275
+ return {
276
+ site: s.site,
277
+ locales: s.locales,
278
+ collections: s.collections.map((c) => ({
279
+ name: c.name,
280
+ label: c.label,
281
+ singleton: c.singleton,
282
+ fields: c.fields.map((f) => ({ name: f.name, kind: f.kind, label: f.label })),
283
+ })),
284
+ };
285
+ },
286
+ }),
287
+ tool({
288
+ name: 'list_entries',
289
+ title: 'Elenco i contenuti',
290
+ description: 'Entries of a collection (newest changes first), with id, title, address and whether published.',
291
+ input: z.object({
292
+ collection: z.string(),
293
+ search: z.string().optional().describe('Words in the title'),
294
+ locale: z.string().optional(),
295
+ limit: z.number().int().min(1).max(100).optional(),
296
+ offset: z.number().int().min(0).optional(),
297
+ }),
298
+ readOnly: true,
299
+ run: async (input) => {
300
+ const q = new URLSearchParams();
301
+ for (const k of ['search', 'locale', 'limit', 'offset'])
302
+ if (input[k] !== undefined)
303
+ q.set(k, String(input[k]));
304
+ return call('GET', `/api/collections/${encodeURIComponent(input.collection)}?${q}`);
305
+ },
306
+ }),
307
+ tool({
308
+ name: 'get_entry',
309
+ title: 'Leggo un contenuto',
310
+ 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.',
311
+ input: z.object({ id: z.string() }),
312
+ readOnly: true,
313
+ run: async (input) => {
314
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(input.id)}`);
315
+ const data = {};
316
+ for (const [k, v] of Object.entries(doc.data))
317
+ data[k] =
318
+ Array.isArray(v) && v[0]?.type
319
+ ? `(page with ${v.length} sections: use get_page)`
320
+ : v;
321
+ return { ...doc, data };
322
+ },
323
+ }),
324
+ tool({
325
+ name: 'create_entry',
326
+ title: 'Creo un contenuto',
327
+ 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.',
328
+ input: z.object({
329
+ collection: z.string(),
330
+ data: z.record(z.string(), z.unknown()),
331
+ locale: z.string().optional(),
332
+ }),
333
+ run: async (input) => {
334
+ const doc = await call('POST', `/api/collections/${encodeURIComponent(input.collection)}`, { data: input.data, locale: input.locale });
335
+ return { id: doc.id, title: doc.title, url: doc.url, issues: doc.issues };
336
+ },
337
+ }),
338
+ tool({
339
+ name: 'update_entry',
340
+ title: 'Modifico un contenuto',
341
+ 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.',
342
+ input: z.object({ id: z.string(), data: z.record(z.string(), z.unknown()) }),
343
+ run: async (input) => {
344
+ const doc = await call('GET', `/api/documents/${encodeURIComponent(input.id)}`);
345
+ const saved = await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
346
+ data: { ...doc.data, ...input.data },
347
+ });
348
+ return { id: saved.id, title: saved.title, url: saved.url, issues: saved.issues };
349
+ },
350
+ }),
351
+ tool({
352
+ name: 'publish_entry',
353
+ title: 'Pubblico',
354
+ description: 'Puts the current draft of an entry on the public site.',
355
+ input: z.object({ id: z.string() }),
356
+ run: async (input) => {
357
+ const doc = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/publish`);
358
+ return { id: doc.id, title: doc.title, url: doc.url, published: true };
359
+ },
360
+ }),
361
+ tool({
362
+ name: 'unpublish_entry',
363
+ title: 'Tolgo dal sito',
364
+ description: 'Takes an entry off the public site; it stays as a draft.',
365
+ input: z.object({ id: z.string() }),
366
+ run: async (input) => {
367
+ const doc = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/unpublish`);
368
+ return { id: doc.id, title: doc.title, published: false };
369
+ },
370
+ }),
371
+ tool({
372
+ name: 'list_translations',
373
+ title: 'Guardo le lingue',
374
+ description: 'The language versions of an entry (its original and translations, with id and status) and the site languages it is still missing.',
375
+ input: z.object({ id: z.string() }),
376
+ readOnly: true,
377
+ run: (input) => call('GET', `/api/documents/${encodeURIComponent(input.id)}/translations`),
378
+ }),
379
+ tool({
380
+ name: 'translate_entry',
381
+ title: 'Traduco',
382
+ 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.',
383
+ input: z.object({
384
+ id: z.string(),
385
+ locale: z.string().describe('A language code from get_site, e.g. "en"'),
386
+ translate: z.boolean().default(true),
387
+ }),
388
+ run: async (input) => {
389
+ const doc = await call('POST', `/api/documents/${encodeURIComponent(input.id)}/translations`, {
390
+ locale: input.locale,
391
+ translate: input.translate,
392
+ });
393
+ return { id: doc.id, title: doc.title, url: doc.url, locale: input.locale, published: false };
394
+ },
395
+ }),
396
+ tool({
397
+ name: 'list_library',
398
+ title: 'Guardo la libreria di sezioni',
399
+ description: 'Ready-made sections that can be added to a page (edit_page → add_section), by category.',
400
+ input: z.object({}),
401
+ readOnly: true,
402
+ run: async () => LIBRARY.map((c) => ({ category: c.label, sections: c.variants.map((v) => `${v.key} (${v.label})`) })),
403
+ }),
404
+ tool({
405
+ name: 'get_theme',
406
+ title: 'Leggo il tema',
407
+ description: 'The design theme: colors (light and dark), fonts, roundness, text styles.',
408
+ input: z.object({}),
409
+ readOnly: true,
410
+ run: async () => (await call('GET', '/api/theme')).theme,
411
+ }),
412
+ tool({
413
+ name: 'update_theme',
414
+ title: 'Modifico il tema',
415
+ 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.',
416
+ input: z.object({ patch: z.record(z.string(), z.unknown()) }),
417
+ run: async (input) => {
418
+ const { theme } = await call('GET', '/api/theme');
419
+ return (await call('PUT', '/api/theme', { theme: merge(theme, input.patch) })).theme;
420
+ },
421
+ }),
422
+ tool({
423
+ name: 'list_media',
424
+ title: 'Guardo la libreria media',
425
+ description: 'Images and files already uploaded, with their address (src) to use in pages and fields.',
426
+ input: z.object({ search: z.string().optional(), limit: z.number().int().min(1).max(100).optional() }),
427
+ readOnly: true,
428
+ run: async (input) => {
429
+ const q = new URLSearchParams({ limit: String(input.limit ?? 40) });
430
+ if (input.search)
431
+ q.set('search', input.search);
432
+ return call('GET', `/api/media?${q}`);
433
+ },
434
+ }),
435
+ tool({
436
+ name: 'upload_media_from_url',
437
+ title: 'Aggiungo un’immagine dal web',
438
+ description: 'Downloads an image from a public https address into the media library and returns its src. Only use images the site may publish.',
439
+ input: z.object({ url: z.url(), alt: z.string().max(500).optional() }),
440
+ run: async (input) => call('POST', '/api/media/from-url', input),
441
+ }),
442
+ ];
443
+ }
444
+ /** Deep merge of plain objects: what the patch has wins, the rest stays. */
445
+ function merge(base, patch) {
446
+ const out = { ...base };
447
+ for (const [k, v] of Object.entries(patch)) {
448
+ const b = base[k];
449
+ out[k] =
450
+ v && typeof v === 'object' && !Array.isArray(v) && b && typeof b === 'object' && !Array.isArray(b)
451
+ ? merge(b, v)
452
+ : v;
453
+ }
454
+ return out;
455
+ }
456
+ /**
457
+ * Calls the API as whoever sent `auth` (cookie or bearer header): in process through the Hono app's
458
+ * `fetch`, or over the network to a running server at `origin` (`materialize mcp`).
459
+ */
460
+ export function apiCaller(fetch, auth, origin = 'http://materialize.internal') {
461
+ return async (method, path, body) => {
462
+ const res = await fetch(new Request(origin + path, {
463
+ method,
464
+ headers: { ...auth, origin, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
465
+ body: body === undefined ? undefined : JSON.stringify(body),
466
+ }));
467
+ const json = (await res.json().catch(() => null));
468
+ if (!res.ok) {
469
+ const issues = json?.error?.issues?.map((i) => `${i.path}: ${i.message}`).join('; ');
470
+ throw new ToolError(`${json?.error?.message ?? `HTTP ${res.status}`}${issues ? ` (${issues})` : ''}`);
471
+ }
472
+ return json;
473
+ };
474
+ }
package/src/totp.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** A new secret: 160 random bits, in the base32 the apps read. */
2
+ export declare const newSecret: () => string;
3
+ export declare const stepAt: (time?: number) => number;
4
+ export declare function codeAt(secret: string, step: number): string;
5
+ /**
6
+ * The time step a code belongs to, or null. One step either side is accepted (a phone's clock
7
+ * may be a little off); steps up to `after` are refused, so a code cannot be used twice.
8
+ */
9
+ export declare function verifyCode(secret: string, code: string, after?: number | null, now?: number): number | null;
10
+ /** What the app scans: the account and the site's name, so people can tell their codes apart. */
11
+ export declare function otpauthUri(secret: string, account: string, issuer: string): string;
12
+ /** Ten one-time recovery codes, for when the phone is lost: "k7f2-9qxm". */
13
+ export declare function recoveryCodes(count?: number): string[];
package/src/totp.js ADDED
@@ -0,0 +1,79 @@
1
+ import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * Two-step verification with an authenticator app (Google Authenticator, 1Password, Authy…):
4
+ * time-based one-time passwords as in RFC 6238, 6 digits every 30 seconds, from Node's crypto.
5
+ */
6
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
7
+ const PERIOD = 30;
8
+ function base32(bytes) {
9
+ let bits = 0;
10
+ let value = 0;
11
+ let out = '';
12
+ for (const byte of bytes) {
13
+ value = (value << 8) | byte;
14
+ bits += 8;
15
+ while (bits >= 5) {
16
+ out += ALPHABET[(value >>> (bits - 5)) & 31];
17
+ bits -= 5;
18
+ }
19
+ }
20
+ if (bits > 0)
21
+ out += ALPHABET[(value << (5 - bits)) & 31];
22
+ return out;
23
+ }
24
+ function fromBase32(text) {
25
+ let bits = 0;
26
+ let value = 0;
27
+ const out = [];
28
+ for (const char of text.replace(/=+$/, '').toUpperCase()) {
29
+ const index = ALPHABET.indexOf(char);
30
+ if (index < 0)
31
+ continue;
32
+ value = (value << 5) | index;
33
+ bits += 5;
34
+ if (bits >= 8) {
35
+ out.push((value >>> (bits - 8)) & 255);
36
+ bits -= 8;
37
+ }
38
+ }
39
+ return Buffer.from(out);
40
+ }
41
+ /** A new secret: 160 random bits, in the base32 the apps read. */
42
+ export const newSecret = () => base32(randomBytes(20));
43
+ export const stepAt = (time = Date.now()) => Math.floor(time / 1000 / PERIOD);
44
+ export function codeAt(secret, step) {
45
+ const counter = Buffer.alloc(8);
46
+ counter.writeBigUInt64BE(BigInt(step));
47
+ const hmac = createHmac('sha1', fromBase32(secret)).update(counter).digest();
48
+ const offset = hmac[hmac.length - 1] & 15;
49
+ const binary = hmac.readUInt32BE(offset) & 0x7fffffff;
50
+ return String(binary % 1_000_000).padStart(6, '0');
51
+ }
52
+ /**
53
+ * The time step a code belongs to, or null. One step either side is accepted (a phone's clock
54
+ * may be a little off); steps up to `after` are refused, so a code cannot be used twice.
55
+ */
56
+ export function verifyCode(secret, code, after = null, now = Date.now()) {
57
+ const clean = code.replace(/\s/g, '');
58
+ if (!/^\d{6}$/.test(clean))
59
+ return null;
60
+ const current = stepAt(now);
61
+ for (const step of [current, current - 1, current + 1]) {
62
+ if (after !== null && step <= after)
63
+ continue;
64
+ if (timingSafeEqual(Buffer.from(codeAt(secret, step)), Buffer.from(clean)))
65
+ return step;
66
+ }
67
+ return null;
68
+ }
69
+ /** What the app scans: the account and the site's name, so people can tell their codes apart. */
70
+ export function otpauthUri(secret, account, issuer) {
71
+ const label = encodeURIComponent(`${issuer}:${account}`);
72
+ return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=${PERIOD}`;
73
+ }
74
+ /** Ten one-time recovery codes, for when the phone is lost: "k7f2-9qxm". */
75
+ export function recoveryCodes(count = 10) {
76
+ const chars = 'abcdefghjkmnpqrstuvwxyz23456789';
77
+ const part = () => Array.from({ length: 4 }, () => chars[randomInt(chars.length)]).join('');
78
+ return Array.from({ length: count }, () => `${part()}-${part()}`);
79
+ }
@@ -0,0 +1,87 @@
1
+ import type { Media, RichText } from '@materializecms/core';
2
+ import { type PageNode } from '@materializecms/core/page';
3
+ /**
4
+ * WordPress, read: from a site's REST API (/wp-json, also with an application password for drafts
5
+ * and private pages) or from its export file (Strumenti → Esporta, WXR). Both become the same
6
+ * list of items, whatever built them (the block editor, Elementor, WPBakery, Divi…): the REST API
7
+ * gives each page as WordPress renders it, the export file its stored content without the
8
+ * page builders' shortcodes.
9
+ */
10
+ export interface WpItem {
11
+ sourceId: string;
12
+ kind: 'post' | 'page';
13
+ title: string;
14
+ slug: string;
15
+ status: 'publish' | 'draft' | 'private' | 'pending' | 'future';
16
+ /** ISO date and time of publication. */
17
+ date: string;
18
+ /** The address on the WordPress site. */
19
+ link: string;
20
+ html: string;
21
+ excerpt: string;
22
+ featured?: {
23
+ url: string;
24
+ alt: string;
25
+ };
26
+ categories: string[];
27
+ tags: string[];
28
+ author?: string;
29
+ seo?: {
30
+ title?: string;
31
+ description?: string;
32
+ };
33
+ }
34
+ export interface WpSite {
35
+ name: string;
36
+ url: string;
37
+ items: WpItem[];
38
+ }
39
+ /** What reading needs from the network: the body as text, the status and the headers. */
40
+ export type Fetcher = (url: string) => Promise<{
41
+ status: number;
42
+ headers: Headers;
43
+ text: string;
44
+ }>;
45
+ export declare class WordPressError extends Error {
46
+ }
47
+ /** HTML or entities to plain text: "Ciao &amp; benvenuti" → "Ciao & benvenuti". */
48
+ export declare const plain: (html: string) => string;
49
+ /**
50
+ * Reads a site through its REST API. `credentials` (user and application password, from the
51
+ * WordPress profile) also brings drafts and private items. `limit` stops early, to try.
52
+ */
53
+ export declare function readRest(base: string, fetcher: Fetcher, options?: {
54
+ limit?: number;
55
+ }): Promise<WpSite>;
56
+ /** Reads the file of Strumenti → Esporta → Tutti i contenuti. */
57
+ export declare function readWxr(xml: string): WpSite;
58
+ /**
59
+ * Stored content of page builders is made of shortcodes ([vc_row], [et_pb_text]…): the marks go,
60
+ * the words and images inside them stay. Unknown wrappers too: they show nothing on their own.
61
+ */
62
+ export declare function withoutShortcodes(html: string): string;
63
+ /**
64
+ * The main content of a whole published page, as HTML: what a theme or a page builder shows
65
+ * between its header and its footer. Used when WordPress keeps the page outside its content
66
+ * (some themes, some page builders), so the API answers empty.
67
+ */
68
+ export declare function mainContent(html: string): string;
69
+ /** What the conversion needs from the import: where images and links now live. */
70
+ export interface ConvertContext {
71
+ /** The library image for a WordPress image address, once downloaded. */
72
+ image: (url: string) => Media | undefined;
73
+ /** The new address for a link to the old site; other links stay as they are. */
74
+ link: (href: string) => string;
75
+ }
76
+ /** Every image address in some HTML, the original size first (WordPress adds "-1024x768"). */
77
+ export declare function imagesIn(html: string): string[];
78
+ /** The full-size original of a resized WordPress image: "foto-1024x768.jpg" → "foto.jpg". */
79
+ export declare const originalOf: (url: string) => string;
80
+ /** HTML to Materialize rich text: paragraphs, titles, lists, quotes and images (from the library). */
81
+ export declare function htmlToRichText(html: string, ctx: ConvertContext): RichText;
82
+ /**
83
+ * HTML to a page on the canvas, plainly laid out: a section for each main title, with its words,
84
+ * images and buttons in order. The layout of the original is redone afterwards (by hand or with
85
+ * the assistant); here nothing of the content is lost.
86
+ */
87
+ export declare function htmlToPage(html: string, title: string, ctx: ConvertContext): PageNode[];