Deutsch
SvelteKit
examples/sveltekit ist eine SvelteKit-3-App (Svelte 5, adapter-node), die den Editor einbettet und ihn über eigene +server.ts-Routen anbindet. SvelteKit 3 liest seine Konfiguration aus vite.config.ts, und das Beispiel importiert aus src/lib über den Subpath-Import #lib/*, den package.json deklariert. Die App speichert Templates und gespeicherte Blöcke als JSON-Dateien unter ./data, rendert HTML-Export und Test-E-Mails auf dem Server mit @templatical/renderer und mjml und schreibt jede Test-E-Mail nach ./data/outbox, statt sie zu versenden. CI baut die App und führt sie bei jeder Änderung am SDK in einem Browser aus.
Ausführen des Beispiels
Oder kopieren Sie es in ein neues Verzeichnis:
bash
npx degit templatical/sdk/examples/sveltekit my-app
cd my-app
npm install
npm run devDie Editor-Komponente
Die Komponente bindet den Editor in onMount ein und gibt den Cleanup zurück, der ihn unmountet – auch einen Editor, der erst fertig lädt, nachdem die Komponente entfernt wurde. Sie importiert init() innerhalb von onMount, daher läuft der Editor nie auf dem Server. Ohne ?id= in der URL legt sie ein Template an und schreibt die neue ID mit SvelteKits goto in die URL. Schlägt das Öffnen des Templates fehl, zeigt die Werkzeugleiste die Meldung des Servers an, etwa „Template not found.“, mit einem Link, der ein neues Template anlegt. Der Link zum Neustart trägt data-sveltekit-reload. Dadurch lädt er die Seite vollständig neu, und die Komponente legt beim erneuten Mounten ein Template an. Die Werkzeugleiste zeigt auch Fehler, die der Editor über onError meldet, etwa eine Bibliothek gespeicherter Blöcke, die nicht lädt, und einen fehlgeschlagenen Export; der nächste Export entfernt sie.
src/lib/EmailEditor.svelte
svelte
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import type { TemplaticalEditor } from "@templatical/editor";
import "@templatical/editor/style.css";
import {
renderProvider,
savedBlocksProvider,
templatesProvider,
testEmailProvider,
} from "#lib/templatical/providers.ts";
type Problem = { message: string; offerRestart: boolean };
// Every message ends as a sentence: the providers' fallback, such as
// "GET /api/templates/… failed (500)", has no full stop and would run into the
// restart link.
const messageOf = (error: unknown) => {
const text = (error instanceof Error ? error.message : String(error)).trim();
return /[.!?]$/.test(text) ? text : `${text}.`;
};
let container: HTMLDivElement;
let editor: TemplaticalEditor | null = null;
let problem = $state<Problem | null>(null);
onMount(() => {
let cancelled = false;
(async () => {
// Imported here, not at the top: the editor runs only in the browser.
const { init } = await import("@templatical/editor");
if (cancelled) return;
const instance = await init({
container,
templates: templatesProvider,
savedBlocks: savedBlocksProvider,
testEmail: testEmailProvider,
render: renderProvider,
onError: (error) => {
if (!cancelled) problem = { message: messageOf(error), offerRestart: false };
},
});
if (cancelled) {
instance.unmount();
return;
}
editor = instance;
// ?id= picks the template; without one, create a template and put its
// id in the URL so a reload reopens the same template.
const id = new URLSearchParams(window.location.search).get("id");
if (id) {
await instance.load(id);
} else {
const template = await instance.create({ name: "Untitled" });
if (!cancelled) await goto(`?id=${template.id}`, { replace: true, shallow: true });
}
})().catch((error: unknown) => {
// The providers throw the server's own message, such as "Template not found."
if (!cancelled) problem = { message: messageOf(error), offerRestart: true };
});
return () => {
cancelled = true;
editor?.unmount();
editor = null;
};
});
async function exportHtml() {
// A new export replaces an earlier failure. A failed load keeps its message
// and restart link: the template it names never opened.
if (!problem?.offerRestart) problem = null;
// Opened inside the click, so a popup blocker allows it, and filled once
// the HTML is ready.
const tab = window.open("", "_blank");
if (!tab) {
problem = { message: "Allow pop-ups for this page to see the export.", offerRestart: false };
return;
}
try {
const html = await editor?.toHtml();
if (!html) {
tab.close();
return;
}
// The HTML renders in a sandboxed frame: HTML blocks and rich text are
// author content, and in this tab they would run with the app's origin.
const frame = tab.document.createElement("iframe");
frame.setAttribute("sandbox", "");
frame.srcdoc = html;
frame.style.cssText = "border: 0; width: 100%; height: 100%";
tab.document.documentElement.style.height = "100%";
tab.document.body.style.cssText = "margin: 0; height: 100%";
tab.document.body.append(frame);
} catch (error) {
tab.close();
problem = { message: messageOf(error), offerRestart: false };
}
}
</script>
<div class="toolbar">
<button type="button" data-testid="export-html" onclick={exportHtml}>Export HTML</button>
{#if problem}
<!-- data-sveltekit-reload makes the restart a full page load: a client-side navigation to "/" keeps this component mounted, so onMount would not run and no template would be created. -->
<span role="alert">
{problem.message}
{#if problem.offerRestart}<a href="/" data-sveltekit-reload>Start a new template</a>.{/if}
</span>
{/if}
</div>
<div bind:this={container} class="editor"></div>Die Provider
Der Editor erreicht das Backend nur über diese Objekte. Jede Methode ist ein fetch an eine der Routen unten. Die Datei ist in den Beispielen für Next.js, Nuxt, SvelteKit und React Router identisch.
src/lib/templatical/providers.ts
ts
// The editor reaches the backend only through these four objects. Each method
// is one fetch to an /api route of this app; point them at any backend that
// implements the same routes, in any language.
import type {
RenderProvider,
SavedBlocksListParams,
SavedBlocksProvider,
TemplatesProvider,
TestEmailProvider,
} from "@templatical/types";
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await fetch(path, {
method,
// Every write declares JSON, the bodyless DELETE included: a framework's
// CSRF check (SvelteKit's, for one) treats a write without a content type
// as a form post and can reject it as cross-site.
headers: method === "GET" ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
const error = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(error?.message ?? `${method} ${path} failed (${response.status})`);
}
return (response.status === 204 ? undefined : await response.json()) as T;
}
function listQuery(params: SavedBlocksListParams = {}): string {
const query = new URLSearchParams();
if (params.search) query.set("search", params.search);
if (params.category) query.set("category", params.category);
const text = query.toString();
return text ? `?${text}` : "";
}
export const templatesProvider: TemplatesProvider = {
load: (id) => request("GET", `/api/templates/${encodeURIComponent(id)}`),
create: (input) => request("POST", "/api/templates", input),
save: (id, patch) => request("PATCH", `/api/templates/${encodeURIComponent(id)}`, patch),
autoSave: true,
};
export const savedBlocksProvider: SavedBlocksProvider = {
list: (params) => request("GET", `/api/saved-blocks${listQuery(params)}`),
create: (input) => request("POST", "/api/saved-blocks", input),
update: (id, patch) => request("PATCH", `/api/saved-blocks/${encodeURIComponent(id)}`, patch),
delete: (id) => request("DELETE", `/api/saved-blocks/${encodeURIComponent(id)}`),
};
export const testEmailProvider: TestEmailProvider = {
defaultRecipient: "[email protected]",
send: ({ recipient, content }) => request("POST", "/api/test-email", { recipient, content }),
};
export const renderProvider: RenderProvider = {
toMjml: async ({ content }) =>
(await request<{ mjml: string }>("POST", "/api/render", { content })).mjml,
toHtml: async ({ content }) =>
(await request<{ html: string }>("POST", "/api/render", { content })).html,
};Die Server-Routen
Jede +server.ts-Datei exportiert einen Handler pro HTTP-Methode. Jeder Handler prüft seine Eingaben, ruft den Store oder den Renderer auf und antwortet mit JSON oder einem leeren 204. Eine abgelehnte Anfrage erhält einen 4xx-Status und { message }, die der Editor anzeigt. Die Handler lesen Request-Bodies mit readJson, das einen Body über dem BODY_SIZE_LIMIT von adapter-node mit einem 413 beantwortet, der die Einstellung nennt.
src/lib/server/read-json.ts
ts
import { error } from "@sveltejs/kit";
/**
* The request body as JSON, or null when it is not JSON. adapter-node fails the
* read of a body larger than BODY_SIZE_LIMIT (512K by default), and that
* answers 413 with a message naming the setting, not a 400 blaming the body.
*/
export async function readJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch (cause) {
if (typeof cause === "object" && cause !== null && "status" in cause && cause.status === 413) {
error(413, "The request body is larger than BODY_SIZE_LIMIT allows (512K by default).");
}
return null;
}
}src/routes/api/templates/+server.ts
ts
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { createTemplate, templateInputError, type TemplateInput } from "#lib/server/templatical/store.ts";
export const POST: RequestHandler = async ({ request }) => {
const body: unknown = await readJson(request);
const problem = templateInputError(body, false);
if (problem) return Response.json({ message: problem }, { status: 400 });
return Response.json(await createTemplate(body as TemplateInput), { status: 201 });
};src/routes/api/templates/[id]/+server.ts
ts
import type { TemplatePatch } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { getTemplate, templateInputError, updateTemplate } from "#lib/server/templatical/store.ts";
const notFound = () => Response.json({ message: "Template not found." }, { status: 404 });
export const GET: RequestHandler = async ({ params }) => {
const template = await getTemplate(params.id);
return template ? Response.json(template) : notFound();
};
export const PATCH: RequestHandler = async ({ params, request }) => {
const body: unknown = await readJson(request);
const problem = templateInputError(body, true);
if (problem) return Response.json({ message: problem }, { status: 400 });
const template = await updateTemplate(params.id, body as TemplatePatch);
return template ? Response.json(template) : notFound();
};src/routes/api/saved-blocks/+server.ts
ts
import type { SavedBlockInput } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { createSavedBlock, listSavedBlocks, savedBlockInputError } from "#lib/server/templatical/store.ts";
export const GET: RequestHandler = async ({ url }) =>
Response.json(
await listSavedBlocks({
search: url.searchParams.get("search") ?? undefined,
category: url.searchParams.get("category") ?? undefined,
}),
);
export const POST: RequestHandler = async ({ request }) => {
const body: unknown = await readJson(request);
const problem = savedBlockInputError(body, false);
if (problem) return Response.json({ message: problem }, { status: 400 });
return Response.json(await createSavedBlock(body as SavedBlockInput), { status: 201 });
};src/routes/api/saved-blocks/[id]/+server.ts
ts
import type { SavedBlockPatch } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { deleteSavedBlock, savedBlockInputError, updateSavedBlock } from "#lib/server/templatical/store.ts";
const notFound = () => Response.json({ message: "Saved block not found." }, { status: 404 });
export const PATCH: RequestHandler = async ({ params, request }) => {
const body: unknown = await readJson(request);
const problem = savedBlockInputError(body, true);
if (problem) return Response.json({ message: problem }, { status: 400 });
const block = await updateSavedBlock(params.id, body as SavedBlockPatch);
return block ? Response.json(block) : notFound();
};
export const DELETE: RequestHandler = async ({ params }) =>
(await deleteSavedBlock(params.id)) ? new Response(null, { status: 204 }) : notFound();src/routes/api/render/+server.ts
ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { renderTemplate } from "#lib/server/templatical/render.ts";
export const POST: RequestHandler = async ({ request }) => {
const body = (await readJson(request)) as { content?: unknown } | null;
if (!isRenderableTemplateContent(body?.content)) {
return Response.json({ message: "`content` must be a template with a `blocks` array." }, { status: 400 });
}
return Response.json(await renderTemplate(body.content));
};src/routes/api/test-email/+server.ts
ts
import { isRenderableTemplateContent } from "@templatical/types";
import type { RequestHandler } from "./$types";
import { readJson } from "#lib/server/read-json.ts";
import { deliver, isEmailAddress } from "#lib/server/templatical/outbox.ts";
import { renderTemplate } from "#lib/server/templatical/render.ts";
export const POST: RequestHandler = async ({ request }) => {
const body = (await readJson(request)) as { recipient?: unknown; content?: unknown } | null;
if (!isEmailAddress(body?.recipient)) {
return Response.json({ message: "Enter a valid email address." }, { status: 400 });
}
if (!isRenderableTemplateContent(body.content)) {
return Response.json({ message: "`content` must be a template with a `blocks` array." }, { status: 400 });
}
const { html } = await renderTemplate(body.content);
await deliver({ to: body.recipient, subject: "Test email", html });
return new Response(null, { status: 204 });
};Die Render- und die Test-E-Mail-Route kompilieren das Template mit renderTemplate() aus src/lib/server/templatical/render.ts:
ts
// Compiles a template to email HTML on the server: the renderer turns the
// editor's JSON into MJML, and the mjml package turns MJML into HTML.
import mjml2html from "mjml";
import { renderToMjml } from "@templatical/renderer";
import type { TemplateContent } from "@templatical/types";
export async function renderTemplate(content: TemplateContent): Promise<{ mjml: string; html: string }> {
const mjml = await renderToMjml(content);
const { html } = await mjml2html(mjml);
return { mjml, html };
}Wechsel in die Produktion
- Ersetzen Sie die Funktionsrümpfe in
src/lib/server/templatical/store.tsdurch Aufrufe Ihrer Datenbank. Jede Route, die Templates oder gespeicherte Blöcke lädt oder speichert, nutzt diese Funktionen. - Ersetzen Sie
deliver()insrc/lib/server/templatical/outbox.tsdurch Ihren E-Mail-Anbieter. Die Funktion erhält Empfänger, Betreff und das fertige HTML. - Sichern Sie die
+server.ts-Routen mit einer Authentifizierung ab. In diesem Beispiel sind sie offen. - Behalten Sie den Header
content-type: application/jsonbei, denproviders.tsbei jedem Schreibzugriff sendet. SvelteKit lehnt einen Schreibzugriff ohne Content-Type als Cross-Site-Formularübermittlung ab, wenn der Origin des Browsers von dem abweicht, den adapter-node annimmt:https://, sofernPROTOCOL_HEADERnicht gesetzt ist. - Bereinigen Sie HTML-Blöcke auf dem Server, bevor Sie sie speichern oder versenden, oder übergeben Sie in
render.tsallowHtmlBlocks: falseanrenderToMjml: Der Editor speichert Autoren-HTML unverändert. - adapter-node lehnt Request-Bodies über 512 KB standardmäßig ab. Setzen Sie
BODY_SIZE_LIMIT(zum BeispielBODY_SIZE_LIMIT=10M), wenn Ihre Templates größer sind.
Backend anbinden behandelt die Provider, die dieses Beispiel auslässt: Versionsverlauf, Kommentare und Medien.