@materializecms/server 0.5.0 → 0.7.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 +21 -0
- package/README.md +2 -0
- package/package.json +4 -4
- package/src/ai.d.ts +6 -1
- package/src/ai.js +11 -3
- package/src/app.d.ts +1 -0
- package/src/app.js +1190 -26
- package/src/brief.d.ts +43 -0
- package/src/brief.js +172 -0
- package/src/mail.d.ts +2 -0
- package/src/newsletter.d.ts +50 -0
- package/src/newsletter.js +192 -0
- package/src/studio.d.ts +13 -0
- package/src/studio.js +75 -0
- package/src/tools.d.ts +1 -0
- package/src/tools.js +57 -2
- package/src/wordpress.d.ts +2 -0
- package/src/wordpress.js +22 -5
- package/src/wpimport.d.ts +19 -1
- package/src/wpimport.js +112 -3
package/src/brief.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type PageNode, type Theme } from '@materializecms/core/page';
|
|
2
|
+
import type { BrandVoice } from '@materializecms/core/voice';
|
|
3
|
+
import { type ApiCall, type SiteTool } from './tools.ts';
|
|
4
|
+
/**
|
|
5
|
+
* A site from a brief: the AI reads a few lines about the business and builds the first pages
|
|
6
|
+
* with the section library. Everything it makes is a new draft: it never changes pages that
|
|
7
|
+
* already exist, shared sections or anything live. Colors, fonts and the brand voice are only
|
|
8
|
+
* proposed; the person applies them with a click.
|
|
9
|
+
*/
|
|
10
|
+
/** At most this many new drafts in one run. */
|
|
11
|
+
export declare const BRIEF_MAX_PAGES = 8;
|
|
12
|
+
export interface BriefResult {
|
|
13
|
+
created: {
|
|
14
|
+
id: string;
|
|
15
|
+
collection: string;
|
|
16
|
+
title: string;
|
|
17
|
+
url: string | null;
|
|
18
|
+
}[];
|
|
19
|
+
/** The whole theme as it would be with the proposal applied. */
|
|
20
|
+
theme: Theme | null;
|
|
21
|
+
voice: BrandVoice | null;
|
|
22
|
+
}
|
|
23
|
+
/** What a page may not carry when the AI makes it: links to shared content that is live. */
|
|
24
|
+
export declare function plain(n: PageNode): PageNode;
|
|
25
|
+
export declare function briefTools(call: ApiCall, options: {
|
|
26
|
+
onCreated: (doc: BriefResult['created'][number]) => void;
|
|
27
|
+
askVoice: boolean;
|
|
28
|
+
/** What the run made so far: a reply to the AI goes on from there. */
|
|
29
|
+
result?: BriefResult;
|
|
30
|
+
}): {
|
|
31
|
+
tools: SiteTool[];
|
|
32
|
+
result: BriefResult;
|
|
33
|
+
};
|
|
34
|
+
/** The instructions for a run, in English like the other prompts; the pages are written in the site's language. */
|
|
35
|
+
export declare function briefSystem(input: {
|
|
36
|
+
site: string;
|
|
37
|
+
language: string;
|
|
38
|
+
collections: string;
|
|
39
|
+
existing: string;
|
|
40
|
+
voice: string;
|
|
41
|
+
askVoice: boolean;
|
|
42
|
+
theme: boolean;
|
|
43
|
+
}): string;
|
package/src/brief.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { normalizeTheme } from '@materializecms/core/page';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { contentTools, pageTools, ToolError } from './tools.js';
|
|
4
|
+
/**
|
|
5
|
+
* A site from a brief: the AI reads a few lines about the business and builds the first pages
|
|
6
|
+
* with the section library. Everything it makes is a new draft: it never changes pages that
|
|
7
|
+
* already exist, shared sections or anything live. Colors, fonts and the brand voice are only
|
|
8
|
+
* proposed; the person applies them with a click.
|
|
9
|
+
*/
|
|
10
|
+
/** At most this many new drafts in one run. */
|
|
11
|
+
export const BRIEF_MAX_PAGES = 8;
|
|
12
|
+
/** What a page may not carry when the AI makes it: links to shared content that is live. */
|
|
13
|
+
export function plain(n) {
|
|
14
|
+
const { global: _g, component: _c, overrides: _o, ...rest } = n;
|
|
15
|
+
return { ...rest, children: n.children?.map(plain) };
|
|
16
|
+
}
|
|
17
|
+
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'a hex color like #1a2b3c');
|
|
18
|
+
const colors = z
|
|
19
|
+
.object({
|
|
20
|
+
bg: color,
|
|
21
|
+
surface: color,
|
|
22
|
+
text: color,
|
|
23
|
+
muted: color,
|
|
24
|
+
accent: color,
|
|
25
|
+
onAccent: color,
|
|
26
|
+
inverse: color,
|
|
27
|
+
onInverse: color,
|
|
28
|
+
})
|
|
29
|
+
.partial();
|
|
30
|
+
export function briefTools(call, options) {
|
|
31
|
+
const result = options.result ?? { created: [], theme: null, voice: null };
|
|
32
|
+
let schema = null;
|
|
33
|
+
const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
|
|
34
|
+
const mine = (id) => {
|
|
35
|
+
if (!id || !result.created.some((d) => d.id === id))
|
|
36
|
+
throw new ToolError('You may change only the drafts you created in this run (create_entry); pages that already exist stay as they are.');
|
|
37
|
+
return id;
|
|
38
|
+
};
|
|
39
|
+
const pageFieldOf = async (collection) => (await getSchema()).collections.find((c) => c.name === collection)?.fields.find((f) => f.kind === 'page')?.name;
|
|
40
|
+
const base = contentTools(call, getSchema);
|
|
41
|
+
const pick = (name) => {
|
|
42
|
+
const t = base.find((x) => x.name === name);
|
|
43
|
+
if (!t)
|
|
44
|
+
throw new Error(`missing tool ${name}`);
|
|
45
|
+
return t;
|
|
46
|
+
};
|
|
47
|
+
const tools = [
|
|
48
|
+
...['get_site', 'list_entries', 'get_entry', 'list_library', 'get_theme', 'list_media'].map(pick),
|
|
49
|
+
{
|
|
50
|
+
...pick('create_entry'),
|
|
51
|
+
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. For a page built on the canvas, leave the page field out and fill it afterwards with edit_page.',
|
|
52
|
+
run: async (input) => {
|
|
53
|
+
if (result.created.length >= BRIEF_MAX_PAGES)
|
|
54
|
+
throw new ToolError(`At most ${BRIEF_MAX_PAGES} new drafts in one run: stop here.`);
|
|
55
|
+
const field = await pageFieldOf(input.collection);
|
|
56
|
+
const data = { ...input.data };
|
|
57
|
+
if (field)
|
|
58
|
+
data[field] = Array.isArray(data[field]) ? data[field].map(plain) : [];
|
|
59
|
+
const doc = await call('POST', `/api/collections/${encodeURIComponent(input.collection)}`, { data, locale: input.locale });
|
|
60
|
+
const created = { id: doc.id, collection: doc.collection, title: doc.title, url: doc.url };
|
|
61
|
+
result.created.push(created);
|
|
62
|
+
options.onCreated(created);
|
|
63
|
+
return { id: doc.id, title: doc.title, url: doc.url, issues: doc.issues };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
...pick('update_entry'),
|
|
68
|
+
run: async (input) => {
|
|
69
|
+
const doc = await call('GET', `/api/documents/${encodeURIComponent(mine(input.id))}`);
|
|
70
|
+
const field = await pageFieldOf(doc.collection);
|
|
71
|
+
const data = { ...doc.data, ...input.data };
|
|
72
|
+
if (field && Array.isArray(data[field]))
|
|
73
|
+
data[field] = data[field].map(plain);
|
|
74
|
+
const saved = await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
|
|
75
|
+
data,
|
|
76
|
+
});
|
|
77
|
+
const entry = result.created.find((d) => d.id === doc.id);
|
|
78
|
+
if (entry)
|
|
79
|
+
Object.assign(entry, { title: saved.title, url: saved.url });
|
|
80
|
+
return { id: saved.id, title: saved.title, url: saved.url, issues: saved.issues };
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
...pageTools({
|
|
84
|
+
withId: true,
|
|
85
|
+
brand: async () => (await getSchema()).site.name,
|
|
86
|
+
source: async (id) => {
|
|
87
|
+
const doc = await call('GET', `/api/documents/${encodeURIComponent(mine(id))}`);
|
|
88
|
+
const field = await pageFieldOf(doc.collection);
|
|
89
|
+
if (!field)
|
|
90
|
+
throw new ToolError(`"${doc.title}" is not a page built on the canvas`);
|
|
91
|
+
let data = doc.data;
|
|
92
|
+
return {
|
|
93
|
+
load: async () => (Array.isArray(data[field]) ? data[field] : []),
|
|
94
|
+
save: async (nodes) => {
|
|
95
|
+
data = { ...data, [field]: nodes.map(plain) };
|
|
96
|
+
await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, { data });
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
{
|
|
102
|
+
name: 'propose_theme',
|
|
103
|
+
title: 'Propongo colori e caratteri',
|
|
104
|
+
description: 'Proposes the site’s colors (light and dark), fonts (Google Fonts names) and roundness. Nothing changes now: the person sees the proposal at the end and applies it if they like it. Call it once, with the whole palette; pages built meanwhile use the theme tokens, so they follow it.',
|
|
105
|
+
input: z.object({
|
|
106
|
+
display: z.string().max(60).optional().describe('Font for titles'),
|
|
107
|
+
body: z.string().max(60).optional().describe('Font for text'),
|
|
108
|
+
colors: z.object({ light: colors.optional(), dark: colors.optional() }).optional(),
|
|
109
|
+
btnRadius: z.number().int().min(0).max(999).optional(),
|
|
110
|
+
cardRadius: z.number().int().min(0).max(64).optional(),
|
|
111
|
+
}),
|
|
112
|
+
run: async (input) => {
|
|
113
|
+
const { theme } = await call('GET', '/api/theme');
|
|
114
|
+
const next = normalizeTheme({
|
|
115
|
+
...theme,
|
|
116
|
+
...(input.display ? { display: input.display, displayCategory: undefined } : {}),
|
|
117
|
+
...(input.body ? { body: input.body, bodyCategory: undefined } : {}),
|
|
118
|
+
...(input.btnRadius !== undefined ? { btnRadius: input.btnRadius } : {}),
|
|
119
|
+
...(input.cardRadius !== undefined ? { cardRadius: input.cardRadius } : {}),
|
|
120
|
+
colors: {
|
|
121
|
+
light: { ...theme.colors.light, ...input.colors?.light },
|
|
122
|
+
dark: { ...theme.colors.dark, ...input.colors?.dark },
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
result.theme = next;
|
|
126
|
+
return 'Proposal kept: the person will choose whether to apply it.';
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
if (options.askVoice)
|
|
131
|
+
tools.push({
|
|
132
|
+
name: 'propose_voice',
|
|
133
|
+
title: 'Propongo la voce del brand',
|
|
134
|
+
description: 'Proposes how the site speaks, from the brief: who the brand is and who it writes for, up to three tones, how readers are addressed, words to prefer and to avoid, other rules. Nothing is saved now: the person sees it at the end. Call it once.',
|
|
135
|
+
input: z.object({
|
|
136
|
+
about: z.string().max(1000).optional(),
|
|
137
|
+
tones: z.array(z.string().max(40)).max(3).optional(),
|
|
138
|
+
address: z.enum(['tu', 'voi', 'lei']).optional(),
|
|
139
|
+
prefer: z.string().max(500).optional(),
|
|
140
|
+
avoid: z.string().max(500).optional(),
|
|
141
|
+
rules: z.string().max(1000).optional(),
|
|
142
|
+
}),
|
|
143
|
+
run: async (input) => {
|
|
144
|
+
result.voice = input;
|
|
145
|
+
return 'Proposal kept: the person will choose whether to save it.';
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return { tools, result };
|
|
149
|
+
}
|
|
150
|
+
/** The instructions for a run, in English like the other prompts; the pages are written in the site's language. */
|
|
151
|
+
export function briefSystem(input) {
|
|
152
|
+
return `${input.voice ? `${input.voice}\n\n` : ''}You are building the first version of the website "${input.site}" in Materialize, a visual CMS. The person describes the business in a brief; you make the pages as drafts they will review, change and publish themselves.
|
|
153
|
+
|
|
154
|
+
How to work:
|
|
155
|
+
1. Read the site (get_site) and the section library (list_library). ${input.theme ? 'Look at the theme (get_theme) and propose colors and fonts that suit the business (propose_theme), once, before building: keep text and background contrast readable (WCAG AA) in light and dark.' : 'Keep the current theme: do not propose another one.'}${input.askVoice ? ' Propose the brand voice (propose_voice), once.' : ''}
|
|
156
|
+
2. Plan a small site that fits the brief: usually a home and 2–4 more pages (at most ${BRIEF_MAX_PAGES}), only the ones the business needs.
|
|
157
|
+
3. For each page: create the draft (create_entry, page field left empty, slug "home" for the home page when the address is free), then fill it with edit_page: add sections from the library (add_section) and rewrite every text for this business. Every page gets the same navigation at the top and footer at the bottom, with links to the pages you make (href "/" for the home, "/slug" for the others). Remove library placeholder texts you do not use; never leave "Lorem ipsum" or invented sample names.
|
|
158
|
+
4. Fill the search engine field of each page when its collection has one (kind "seo", see get_site) with update_entry: {"title": up to 60 characters, "description": 120–160 characters}.
|
|
159
|
+
5. Read each page once more (get_page) and fix what is left.
|
|
160
|
+
|
|
161
|
+
Rules:
|
|
162
|
+
- Write every text in ${input.language}, for real readers: concrete, short, no clichés.
|
|
163
|
+
- Use only facts from the brief. Where a fact is missing (prices, addresses, phone numbers, names, numbers, testimonials), write a clear placeholder in square brackets, e.g. "[Indirizzo dello studio]", so the person sees what to fill in. Never invent reviews, clients, awards or figures.
|
|
164
|
+
- Images: use pictures already in the media library (list_media) when they fit; otherwise keep the library's placeholder images and write a useful alt text describing the picture to add.
|
|
165
|
+
- Pages that already exist stay untouched; do not create a page whose address is already taken (existing pages are listed below).
|
|
166
|
+
- Reference pictures, when attached, show a look the person likes: take their mood, layout and colors, never their brand, logos or texts.
|
|
167
|
+
|
|
168
|
+
Collections with pages built on the canvas: ${input.collections}
|
|
169
|
+
Existing entries: ${input.existing}
|
|
170
|
+
|
|
171
|
+
When done, reply with a short summary in ${input.language}: the pages made and what the person should complete (the bracketed placeholders, images). No lists of technical steps.`;
|
|
172
|
+
}
|
package/src/mail.d.ts
CHANGED
|
@@ -30,7 +30,9 @@ export declare function sendMail(m: MailSettings, message: {
|
|
|
30
30
|
to: string;
|
|
31
31
|
subject: string;
|
|
32
32
|
text: string;
|
|
33
|
+
html?: string;
|
|
33
34
|
replyTo?: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
34
36
|
}): Promise<void>;
|
|
35
37
|
/** A message from a site form as plain text: one "Label: value" per field. */
|
|
36
38
|
export declare function submissionText(page: string, data: Record<string, string>): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The newsletter as an email: an HTML version that reads well in every email app (one column,
|
|
3
|
+
* inline styles, absolute addresses) and a plain text one. With statistics on, a tiny picture
|
|
4
|
+
* tells when it is opened and links pass through the site, signed so they cannot be bent to
|
|
5
|
+
* send people elsewhere.
|
|
6
|
+
*/
|
|
7
|
+
type Lang = 'it' | 'en';
|
|
8
|
+
export declare const langOf: (locale: string | null | undefined) => Lang;
|
|
9
|
+
export interface NewsletterContent {
|
|
10
|
+
site: string;
|
|
11
|
+
/** The site's public address, for links and pictures. */
|
|
12
|
+
siteUrl: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
preheader: string;
|
|
15
|
+
body: unknown[];
|
|
16
|
+
/** The site's accent color, for links and buttons. */
|
|
17
|
+
accent: string;
|
|
18
|
+
/** The sender's details, shown at the bottom (often required by law: a postal address). */
|
|
19
|
+
footer: string;
|
|
20
|
+
unsubscribeUrl: string;
|
|
21
|
+
/** Statistics: the picture that tells the email was opened, and each link through the site. */
|
|
22
|
+
openUrl?: string;
|
|
23
|
+
link?: (href: string) => string;
|
|
24
|
+
locale?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function newsletterHtml(c: NewsletterContent): string;
|
|
27
|
+
/** The same newsletter as plain text, for email apps that show no HTML. */
|
|
28
|
+
export declare function newsletterText(c: NewsletterContent): string;
|
|
29
|
+
/** The email that asks a new subscriber to confirm. */
|
|
30
|
+
export declare function confirmEmail(input: {
|
|
31
|
+
site: string;
|
|
32
|
+
url: string;
|
|
33
|
+
accent: string;
|
|
34
|
+
locale?: string | null;
|
|
35
|
+
}): {
|
|
36
|
+
subject: string;
|
|
37
|
+
text: string;
|
|
38
|
+
html: string;
|
|
39
|
+
};
|
|
40
|
+
/** The signature of a tracked link: without it, the address would redirect anywhere. */
|
|
41
|
+
export declare function linkSignature(sendId: string, url: string, secret: string): string;
|
|
42
|
+
export declare function validLink(sendId: string, url: string, signature: string, secret: string): boolean;
|
|
43
|
+
/** A 1×1 transparent GIF: the "opened" picture. */
|
|
44
|
+
export declare const PIXEL: Buffer<ArrayBuffer>;
|
|
45
|
+
/** Addresses out of a CSV export (Mailchimp, Brevo, a spreadsheet): the email column, and a name if there is one. */
|
|
46
|
+
export declare function parseSubscribersCsv(text: string): {
|
|
47
|
+
email: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
}[];
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { richTextHtml } from '@materializecms/core/page';
|
|
3
|
+
const WORDS = {
|
|
4
|
+
it: {
|
|
5
|
+
unsubscribe: 'Non vuoi più ricevere queste email? Disiscriviti',
|
|
6
|
+
why: 'Ricevi questa email perché ti sei iscritto alla newsletter di {site}.',
|
|
7
|
+
confirmSubject: 'Conferma l’iscrizione alla newsletter di {site}',
|
|
8
|
+
confirmIntro: 'Ciao! Per ricevere la newsletter di {site} conferma il tuo indirizzo:',
|
|
9
|
+
confirmButton: 'Confermo l’iscrizione',
|
|
10
|
+
confirmIgnore: 'Se non ti sei iscritto tu, ignora questa email: senza conferma non riceverai niente.',
|
|
11
|
+
view: 'Apri nel browser',
|
|
12
|
+
},
|
|
13
|
+
en: {
|
|
14
|
+
unsubscribe: 'Don’t want these emails any more? Unsubscribe',
|
|
15
|
+
why: 'You get this email because you signed up for the {site} newsletter.',
|
|
16
|
+
confirmSubject: 'Confirm your subscription to the {site} newsletter',
|
|
17
|
+
confirmIntro: 'Hi! To get the {site} newsletter, confirm your address:',
|
|
18
|
+
confirmButton: 'Confirm my subscription',
|
|
19
|
+
confirmIgnore: 'If you did not sign up, ignore this email: without confirming you will get nothing.',
|
|
20
|
+
view: 'Open in your browser',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export const langOf = (locale) => locale?.toLowerCase().startsWith('it') ? 'it' : 'en';
|
|
24
|
+
const say = (lang, key, site) => (WORDS[lang][key] ?? key).replace('{site}', site);
|
|
25
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
26
|
+
/** An address on the site made absolute; others stay as they are. */
|
|
27
|
+
const absolute = (href, base) => {
|
|
28
|
+
try {
|
|
29
|
+
return new URL(href, `${base.replace(/\/$/, '')}/`).toString();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return href;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** Inline styles: email apps drop style sheets. */
|
|
36
|
+
const STYLE = {
|
|
37
|
+
p: 'margin:0 0 16px;font-size:16px;line-height:1.6;',
|
|
38
|
+
h2: 'margin:28px 0 12px;font-size:22px;line-height:1.3;font-weight:700;',
|
|
39
|
+
h3: 'margin:24px 0 10px;font-size:18px;line-height:1.35;font-weight:700;',
|
|
40
|
+
h4: 'margin:20px 0 8px;font-size:16px;line-height:1.4;font-weight:700;',
|
|
41
|
+
ul: 'margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;',
|
|
42
|
+
li: 'margin:0 0 6px;',
|
|
43
|
+
blockquote: 'margin:0 0 16px;padding:4px 0 4px 16px;border-left:3px solid #d4d4d8;color:#52525b;font-style:italic;',
|
|
44
|
+
figure: 'margin:0 0 16px;',
|
|
45
|
+
img: 'display:block;max-width:100%;height:auto;border:0;border-radius:8px;',
|
|
46
|
+
};
|
|
47
|
+
export function newsletterHtml(c) {
|
|
48
|
+
const lang = langOf(c.locale);
|
|
49
|
+
let body = richTextHtml(c.body);
|
|
50
|
+
body = body.replace(/(src|href)="([^"]*)"/g, (_, attr, value) => {
|
|
51
|
+
const raw = value.replace(/&/g, '&');
|
|
52
|
+
const abs = absolute(raw, c.siteUrl);
|
|
53
|
+
const out = attr === 'href' && c.link && /^https?:/i.test(abs) ? c.link(abs) : abs;
|
|
54
|
+
return `${attr}="${esc(out)}"`;
|
|
55
|
+
});
|
|
56
|
+
body = body.replace(/<(p|h2|h3|h4|ul|li|blockquote|figure|img)(\s|>)/g, (_, tag, after) => {
|
|
57
|
+
return `<${tag} style="${STYLE[tag]}"${after}`;
|
|
58
|
+
});
|
|
59
|
+
body = body.replace(/<a /g, `<a style="color:${esc(c.accent)};text-decoration:underline;" `);
|
|
60
|
+
body = body.replace(/ loading="lazy" decoding="async"/g, '');
|
|
61
|
+
const footer = c.footer.trim() ? `<p style="margin:0 0 8px;">${esc(c.footer.trim()).replace(/\n/g, '<br>')}</p>` : '';
|
|
62
|
+
return `<!doctype html>
|
|
63
|
+
<html lang="${lang}">
|
|
64
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(c.subject)}</title></head>
|
|
65
|
+
<body style="margin:0;padding:0;background:#f4f4f5;">
|
|
66
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${esc(c.preheader)}</div>
|
|
67
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;">
|
|
68
|
+
<tr><td align="center" style="padding:32px 16px;">
|
|
69
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:12px;">
|
|
70
|
+
<tr><td style="padding:28px 32px 8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;font-weight:700;color:#18181b;">${esc(c.site)}</td></tr>
|
|
71
|
+
<tr><td style="padding:8px 32px 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
|
|
72
|
+
<h1 style="margin:0 0 20px;font-size:26px;line-height:1.25;font-weight:700;">${esc(c.subject)}</h1>
|
|
73
|
+
${body}
|
|
74
|
+
</td></tr>
|
|
75
|
+
</table>
|
|
76
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;">
|
|
77
|
+
<tr><td style="padding:20px 32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:1.6;color:#71717a;text-align:center;">
|
|
78
|
+
${footer}<p style="margin:0 0 8px;">${esc(say(lang, 'why', c.site))}</p>
|
|
79
|
+
<p style="margin:0;"><a href="${esc(c.unsubscribeUrl)}" style="color:#71717a;text-decoration:underline;">${esc(say(lang, 'unsubscribe', c.site))}</a></p>
|
|
80
|
+
</td></tr>
|
|
81
|
+
</table>
|
|
82
|
+
</td></tr>
|
|
83
|
+
</table>
|
|
84
|
+
${c.openUrl ? `<img src="${esc(c.openUrl)}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;">` : ''}
|
|
85
|
+
</body>
|
|
86
|
+
</html>`;
|
|
87
|
+
}
|
|
88
|
+
/** The same newsletter as plain text, for email apps that show no HTML. */
|
|
89
|
+
export function newsletterText(c) {
|
|
90
|
+
const lang = langOf(c.locale);
|
|
91
|
+
const lines = [c.subject, ''];
|
|
92
|
+
const runs = (b) => (b.children ?? [])
|
|
93
|
+
.map((r) => (r.href ? `${r.text ?? ''} (${absolute(r.href, c.siteUrl)})` : (r.text ?? '')))
|
|
94
|
+
.join('');
|
|
95
|
+
for (const b of c.body) {
|
|
96
|
+
if (b.type === 'image')
|
|
97
|
+
continue;
|
|
98
|
+
const text = runs(b).trim();
|
|
99
|
+
if (!text)
|
|
100
|
+
continue;
|
|
101
|
+
if (b.type === 'listItem')
|
|
102
|
+
lines.push(`- ${text}`);
|
|
103
|
+
else if (b.type === 'heading')
|
|
104
|
+
lines.push('', text.toUpperCase(), '');
|
|
105
|
+
else
|
|
106
|
+
lines.push(text, '');
|
|
107
|
+
}
|
|
108
|
+
lines.push('—', ...(c.footer.trim() ? [c.footer.trim()] : []), say(lang, 'why', c.site));
|
|
109
|
+
lines.push(`${say(lang, 'unsubscribe', c.site)}: ${c.unsubscribeUrl}`);
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
|
112
|
+
/** The email that asks a new subscriber to confirm. */
|
|
113
|
+
export function confirmEmail(input) {
|
|
114
|
+
const lang = langOf(input.locale);
|
|
115
|
+
const subject = say(lang, 'confirmSubject', input.site);
|
|
116
|
+
const text = [say(lang, 'confirmIntro', input.site), '', input.url, '', say(lang, 'confirmIgnore', input.site)].join('\n');
|
|
117
|
+
const html = `<!doctype html><html lang="${lang}"><body style="margin:0;padding:32px 16px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
|
|
118
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;"><tr><td style="padding:28px 32px;">
|
|
119
|
+
<p style="margin:0 0 20px;font-size:16px;line-height:1.6;">${esc(say(lang, 'confirmIntro', input.site))}</p>
|
|
120
|
+
<p style="margin:0 0 24px;"><a href="${esc(input.url)}" style="display:inline-block;padding:12px 20px;border-radius:8px;background:${esc(input.accent)};color:#ffffff;font-weight:600;text-decoration:none;">${esc(say(lang, 'confirmButton', input.site))}</a></p>
|
|
121
|
+
<p style="margin:0;font-size:13px;line-height:1.6;color:#71717a;">${esc(say(lang, 'confirmIgnore', input.site))}</p>
|
|
122
|
+
</td></tr></table></body></html>`;
|
|
123
|
+
return { subject, text, html };
|
|
124
|
+
}
|
|
125
|
+
/** The signature of a tracked link: without it, the address would redirect anywhere. */
|
|
126
|
+
export function linkSignature(sendId, url, secret) {
|
|
127
|
+
return createHmac('sha256', secret).update(`${sendId}\n${url}`).digest('base64url').slice(0, 22);
|
|
128
|
+
}
|
|
129
|
+
export function validLink(sendId, url, signature, secret) {
|
|
130
|
+
const expected = Buffer.from(linkSignature(sendId, url, secret));
|
|
131
|
+
const given = Buffer.from(signature);
|
|
132
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
133
|
+
}
|
|
134
|
+
/** A 1×1 transparent GIF: the "opened" picture. */
|
|
135
|
+
export const PIXEL = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
|
|
136
|
+
/** Addresses out of a CSV export (Mailchimp, Brevo, a spreadsheet): the email column, and a name if there is one. */
|
|
137
|
+
export function parseSubscribersCsv(text) {
|
|
138
|
+
const lines = text
|
|
139
|
+
.replace(/^/, '')
|
|
140
|
+
.split(/\r?\n/)
|
|
141
|
+
.filter((l) => l.trim());
|
|
142
|
+
if (!lines.length)
|
|
143
|
+
return [];
|
|
144
|
+
const sep = [';', ',', '\t'].sort((a, b) => (lines[0]?.split(b).length ?? 0) - (lines[0]?.split(a).length ?? 0))[0] ?? ',';
|
|
145
|
+
const cells = (l) => {
|
|
146
|
+
const out = [];
|
|
147
|
+
let cur = '';
|
|
148
|
+
let quoted = false;
|
|
149
|
+
for (let i = 0; i < l.length; i++) {
|
|
150
|
+
const ch = l[i];
|
|
151
|
+
if (ch === '"') {
|
|
152
|
+
if (quoted && l[i + 1] === '"') {
|
|
153
|
+
cur += '"';
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
quoted = !quoted;
|
|
158
|
+
}
|
|
159
|
+
else if (ch === sep && !quoted) {
|
|
160
|
+
out.push(cur);
|
|
161
|
+
cur = '';
|
|
162
|
+
}
|
|
163
|
+
else
|
|
164
|
+
cur += ch;
|
|
165
|
+
}
|
|
166
|
+
out.push(cur);
|
|
167
|
+
return out.map((c) => c.trim());
|
|
168
|
+
};
|
|
169
|
+
const head = cells(lines[0] ?? '').map((h) => h.toLowerCase());
|
|
170
|
+
const isEmail = (v) => /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(v);
|
|
171
|
+
let emailCol = head.findIndex((h) => /e-?mail/.test(h));
|
|
172
|
+
const nameCol = head.findIndex((h) => /^(name|nome|first name|nome e cognome|full name)$/.test(h));
|
|
173
|
+
let rows = lines.slice(1);
|
|
174
|
+
// No header: the column holding addresses, from the first line on.
|
|
175
|
+
if (emailCol < 0) {
|
|
176
|
+
emailCol = cells(lines[0] ?? '').findIndex(isEmail);
|
|
177
|
+
rows = lines;
|
|
178
|
+
}
|
|
179
|
+
if (emailCol < 0)
|
|
180
|
+
return [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const l of rows) {
|
|
184
|
+
const c = cells(l);
|
|
185
|
+
const email = (c[emailCol] ?? '').toLowerCase();
|
|
186
|
+
if (!isEmail(email) || seen.has(email))
|
|
187
|
+
continue;
|
|
188
|
+
seen.add(email);
|
|
189
|
+
out.push({ email, ...(nameCol >= 0 && c[nameCol] ? { name: c[nameCol] } : {}) });
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
package/src/studio.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ApiCall, type SiteTool } from './tools.ts';
|
|
2
|
+
export interface Touched {
|
|
3
|
+
id: string;
|
|
4
|
+
collection: string;
|
|
5
|
+
title: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function studioTools(call: ApiCall, onTouched: (doc: Touched) => void): SiteTool[];
|
|
8
|
+
export declare function studioSystem(input: {
|
|
9
|
+
site: string;
|
|
10
|
+
user: string;
|
|
11
|
+
voice: string;
|
|
12
|
+
limited: boolean;
|
|
13
|
+
}): string;
|
package/src/studio.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { collapseComponents, collapseGlobals, expandComponents, expandGlobals, } from '@materializecms/core/page';
|
|
2
|
+
import { contentTools, pageTools, ToolError } from './tools.js';
|
|
3
|
+
/**
|
|
4
|
+
* The request on the dashboard: the AI works on the whole site, in drafts only. It cannot
|
|
5
|
+
* publish, schedule, share or change what is live at once (the theme, shared sections,
|
|
6
|
+
* components): those stay with the person, in the panel.
|
|
7
|
+
*/
|
|
8
|
+
/** Tools that act on the live site at once: left out. */
|
|
9
|
+
const LIVE = new Set([
|
|
10
|
+
'publish_entry',
|
|
11
|
+
'unpublish_entry',
|
|
12
|
+
'schedule_entry',
|
|
13
|
+
'share_preview',
|
|
14
|
+
'update_theme',
|
|
15
|
+
'publish_release',
|
|
16
|
+
'schedule_release',
|
|
17
|
+
]);
|
|
18
|
+
export function studioTools(call, onTouched) {
|
|
19
|
+
let schema = null;
|
|
20
|
+
const getSchema = async () => (schema ??= await call('GET', '/api/schema'));
|
|
21
|
+
const touched = (doc) => doc.collection && onTouched({ id: doc.id, collection: doc.collection, title: doc.title });
|
|
22
|
+
const base = contentTools(call, getSchema)
|
|
23
|
+
.filter((t) => !LIVE.has(t.name))
|
|
24
|
+
.map((t) => ['create_entry', 'update_entry', 'translate_entry'].includes(t.name)
|
|
25
|
+
? {
|
|
26
|
+
...t,
|
|
27
|
+
run: async (input) => {
|
|
28
|
+
const out = (await t.run(input));
|
|
29
|
+
if (out?.id) {
|
|
30
|
+
const doc = await call('GET', `/api/documents/${encodeURIComponent(out.id)}`);
|
|
31
|
+
touched(doc);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
: t);
|
|
37
|
+
const page = pageTools({
|
|
38
|
+
withId: true,
|
|
39
|
+
brand: async () => (await getSchema()).site.name,
|
|
40
|
+
source: async (id) => {
|
|
41
|
+
if (!id)
|
|
42
|
+
throw new ToolError('Say which page: its id');
|
|
43
|
+
const doc = await call('GET', `/api/documents/${encodeURIComponent(id)}`);
|
|
44
|
+
const def = (await getSchema()).collections.find((c) => c.name === doc.collection);
|
|
45
|
+
const field = def?.fields.find((f) => f.kind === 'page')?.name;
|
|
46
|
+
if (!field)
|
|
47
|
+
throw new ToolError(`"${doc.title}" is not a page built on the canvas`);
|
|
48
|
+
const { globals } = await call('GET', '/api/globals');
|
|
49
|
+
const { components } = await call('GET', '/api/components');
|
|
50
|
+
return {
|
|
51
|
+
load: async () => expandComponents(expandGlobals(Array.isArray(doc.data[field]) ? doc.data[field] : [], globals), components),
|
|
52
|
+
save: async (nodes) => {
|
|
53
|
+
const withRefs = collapseComponents(nodes, components);
|
|
54
|
+
const collapsed = collapseGlobals(withRefs.nodes);
|
|
55
|
+
const changedShared = JSON.stringify(withRefs.components) !== JSON.stringify(components) ||
|
|
56
|
+
Object.entries(collapsed.globals).some(([k, v]) => JSON.stringify(v) !== JSON.stringify(globals[k]));
|
|
57
|
+
if (changedShared)
|
|
58
|
+
throw new ToolError('That changes a shared section or a component, which is live on every page at once: leave it as it is and tell the person to change it in the editor. Nothing was saved.');
|
|
59
|
+
await call('PUT', `/api/documents/${encodeURIComponent(doc.id)}`, {
|
|
60
|
+
data: { ...doc.data, [field]: collapsed.nodes },
|
|
61
|
+
});
|
|
62
|
+
touched(doc);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return [...base, ...page];
|
|
68
|
+
}
|
|
69
|
+
export function studioSystem(input) {
|
|
70
|
+
return `${input.voice ? `${input.voice}\n\n` : ''}You are the assistant on the dashboard of Materialize, the CMS of the website "${input.site}". ${input.user} asks you for something about the site: pages, articles, texts, translations, the search engine fields.
|
|
71
|
+
|
|
72
|
+
Work with the tools: look first (get_site, list_entries, get_entry, get_page), then change. Every change goes into drafts: you cannot publish, schedule, share or change the theme, shared sections (menus, footers) or components. When something needs those, do the rest and say what the person should do in the panel.
|
|
73
|
+
Change only what was asked; keep the design and the ids of existing nodes; prefer library sections for new ones. Use only facts you find on the site or in the request: where one is missing, write a placeholder in square brackets.
|
|
74
|
+
${input.limited ? 'This person may change only contents (texts, links, images), not layout or design.\n' : ''}When done, reply in the person's language in two or three short sentences: what you changed and what to check before publishing. If you could not do something, say so.`;
|
|
75
|
+
}
|