@singleton-sd/post-kit-editor
v0.7.0
Published
React admin editor component for PostKit email templates
Readme
@singleton-sd/post-kit-editor
React admin editor component for PostKit email templates. It
edits the three Git-backed source files (template.json, metadata.json,
preview.json) in memory, with Save and optional Send-test controls that
call consumer-supplied callbacks. The package never writes to disk, Git, or
the network, and never accepts API keys or other credentials as props.
Installation
pnpm add @singleton-sd/post-kit-editorReact is a peer dependency — the consumer application owns the React instance:
pnpm add react@^18.3.1 react-dom@^18.3.1Usage
import {
EmailTemplateEditor,
type TemplateSourceFiles,
type SerializedTemplateSource,
type ValidationIssue,
} from '@singleton-sd/post-kit-editor';
export function TemplateAdminPage({
template,
loading,
loadError,
}: {
template: TemplateSourceFiles;
loading?: boolean;
loadError?: string;
}) {
return (
<EmailTemplateEditor
template={template}
availableVariables={[{ name: 'name', description: 'Recipient display name' }]}
loading={loading}
loadError={loadError}
onSave={async (serialized: SerializedTemplateSource, files: TemplateSourceFiles) => {
// Commit serialized.templateJson / metadataJson / previewJson to the
// consumer repository (e.g. via the app's own server endpoint).
const res = await fetch('/api/templates', {
method: 'PUT',
body: JSON.stringify({ serialized, key: files.metadata.key }),
});
// fetch() resolves for HTTP 4xx/5xx — return failure so the editor
// keeps dirty state and does not treat the rejection as success.
if (!res.ok) {
return { ok: false, message: 'Save failed.' };
}
}}
onSendTest={async (serialized, _files, recipient) => {
// Browser → your trusted server only. The server uses
// @singleton-sd/post-kit-client with secrets from Azure Key Vault
// (production) or local `.env` (development). Never embed a
// long-lived PostKit API key in browser code.
const res = await fetch('/api/templates/send-test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serialized, recipient }),
});
if (!res.ok) {
return { ok: false, message: 'Test send failed.' };
}
}}
onDirtyChange={(dirty) => {
// Optional: guard in-app navigation while dirty.
}}
onValidationChange={(issues: ValidationIssue[]) => {
// Optional: mirror validation in the host chrome.
}}
onPreviewRendered={(html) => {
console.log('preview bytes', html.length);
}}
className="tenant-theme"
/>
);
}A copy-pasteable single-file integration (plus synthetic sample JSON) lives in
examples/minimal/.
Props
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| template | TemplateSourceFiles | yes | Seeded working triple (templateJson, metadata, previewData). |
| onSave | (serialized, files) => SaveResult \| void \| Promise<…> | yes | Persist working files. Receives serialized Git strings and structured files. |
| onSendTest | (serialized, files, recipient) => SendTestResult \| void \| Promise<…> | no | When set, shows Send-test chrome. Must route through the consumer’s trusted server. |
| availableVariables | TemplateVariable[] | no | Catalogue entries offered to the editing user. |
| onDirtyChange | (dirty: boolean) => void | no | Fires when working state diverges from the seeded template (resets after successful save). |
| onValidationChange | (issues: ValidationIssue[]) => void | no | Current validation issues whenever they change. |
| onPreviewRendered | (html: string) => void | no | Successful preview HTML (e.g. open-in-new-tab without recompiling). |
| loading | boolean | no | Non-interactive loading shell while the host fetches files. |
| loadError | string | no | Non-interactive error shell with the host’s message. |
| className | string | no | Extra class on the root element. |
Also exported: loadTemplateSource, serializeTemplateSource,
validateTemplate, hasValidationErrors, EDITOR_CLASS_PREFIX, and related
types.
What this package does not do
- No persistence — it never writes to disk, Git, or object storage.
onSaveis the only way contents leave the editor. - No sending — it never calls PostKit or any mail transport.
onSendTest(when provided) is a consumer callback only. - No credentials — there are no API-key / token props. Secrets stay on the consumer’s trusted server (Key Vault / env), never in browser bundles.
- No publish pipeline — compilation and content hashing belong to
@singleton-sd/post-kit-compilerin CI or server tooling.
Persistence is consumer-supplied
Save serializes the working state with serializeTemplateSource() and passes
both the SerializedTemplateSource strings and the structured
TemplateSourceFiles to onSave. The editor shows pending / success / failure
feedback and re-enables the control in every outcome. Rejected promises become
failure messages (no unhandled rejections).
Send-test chrome appears only when onSendTest is provided. The editor
validates a non-empty, plausible recipient address, then invokes the callback
with the same serialized payload plus the recipient. Test delivery must go
through the consumer's trusted server (typically @singleton-sd/post-kit-client
server-side).
Optional onDirtyChange fires when working state diverges from the seeded
template prop and resets after a successful save.
Validation and accessibility
validateTemplate(files) (also exported) checks required metadata, key charset,
undeclared / unused variables, preview coverage, and optional render-failed
from the preview pane (passed through so the compiler is not invoked twice).
Error-severity issues disable Save and Send-test; warnings never block.
Optional props:
onValidationChange— currentValidationIssue[]whenever it changesloading— non-interactive loading shell while the host fetches filesloadError— non-interactive error shell with the host’s message
Inline field errors use aria-describedby. The validation summary is an
aria-live region; each entry focuses the responsible control.
Preview data (synthetic only)
preview.json is edited in the preview-data panel and used to render the
sandboxed preview pane via @singleton-sd/post-kit-compiler/preview
renderPreview (same EmailBuilder + Handlebars path as publish). Those sample
values are committed to the consumer repository with the template source.
Never put real personal data, customer addresses, or secrets in preview
data. Keep values synthetic (e.g. Jane Doe, [email protected]).
The preview pane shows compiler errors inline when render fails and leaves the
canvas / metadata editable. Re-renders are debounced while typing. Optional
onPreviewRendered receives the HTML string on each successful render.
Browser note: preview imports @singleton-sd/post-kit-compiler/preview, which
has no Node built-ins. Full compile() (content hashing + filesystem) remains
on the package root for CI and publish tooling. The preview iframe applies a
restrictive CSP (connect-src 'none', img-src data: only) so template HTML
cannot trigger arbitrary network fetches from the admin page.
Styling
The editor ships plain CSS classes, no CSS-in-JS runtime and no component
library. Every class is prefixed with the exported EDITOR_CLASS_PREFIX
(pk-editor-), so class names are stable and safe to target from a consumer
stylesheet — for example .pk-editor-root. The root element also accepts a
className prop as an escape hatch for theme or layout classes.
Component tests
Tests run on Node's built-in test runner with tsx, consistent with the rest of
the repo — no browser-based test stack, no jsdom.
Components are rendered to static markup with react-dom/server and asserted
against the resulting HTML string:
import { renderToStaticMarkup } from 'react-dom/server';
const html = renderToStaticMarkup(
<EmailTemplateEditor template={template} onSave={() => {}} />,
);Specs live next to the code as src/**/*.spec.tsx. Behaviour that needs
interaction is factored into pure functions (preview rows, save/send helpers)
that can be tested without a DOM. The end-to-end suite (src/e2e.spec.tsx)
drives load → edit → validate → save / send-test through those helpers plus SSR
markup.
Development
pnpm test # type-check + run tests
pnpm build # emit CommonJS to dist/
pnpm lint # covered by root eslintexamples/ is documentation-only: it is not listed in the package files
array and is outside src/, so it is neither published to npm nor emitted into
dist/.
