Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ The email preset calls these. They stay public so a part, or a caller, can use t
| `footer({ companyName?, unsubscribeUrl? })` | `<footer>` with the company name and an optional unsubscribe link |
| `body({ preheaderHtml?, mainHtml?, footerHtml?, attributes? })` | `<body>` around an optional preheader, main, and footer. Optional raw attributes. |
| `content({ content? })` | A `<div>` around raw HTML. The email shell does not call it |
| `document({ headHtml?, mainHtml?, footerHtml?, doctype?, lang?, htmlAttributes?, bodyAttributes? })` | Doctype, `<html>`, head, and body. Defaults to HTML5 + `lang="en"`. Supports XHTML, omitting `lang`, and body attributes. |
| `document({ headHtml?, mainHtml?, footerHtml?, doctype?, lang?, htmlAttributes?, bodyAttributes? })` | Doctype, `<html>`, head, and body. Defaults to HTML5 + `lang="en"`. Attributes accept a raw string or a structured map. |

Omitted strings render empty. The button is present only when both `ctaLabel` and `ctaUrl` are set. The unsubscribe link is present only when `unsubscribeUrl` is set.

Expand Down
4 changes: 2 additions & 2 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ document({
|---|---|
| `doctype` | `<!DOCTYPE html>` |
| `lang` | `"en"`. Pass `false` or `null` to omit the attribute |
| `htmlAttributes` | raw extra attributes on `<html>` (e.g. `xmlns="..."`) |
| `bodyAttributes` | raw extra attributes on `<body>` (e.g. `style="margin:0"`) |
| `htmlAttributes` | raw string or structured map (e.g. `{ xmlns: '...' }`) |
| `bodyAttributes` | raw string or structured map (e.g. `{ style: 'margin:0' }`) |
| `headHtml` | empty |
| (BodyProps) | passed through to `body` |

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ export type { ContentProps } from './templates/content/content';

export { document } from './templates/document/document';
export type { DocumentProps, DocumentShellOptions } from './templates/document/document';
export { openTag, serializeAttributes } from './utils/openTag';
export type { TagAttributes } from './utils/openTag';
10 changes: 6 additions & 4 deletions src/templates/body/body.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { openTag } from '../../utils/openTag';
import type { TagAttributes } from '../../utils/openTag';

export interface BodyProps {
/** Raw HTML for the top of `<body>` (e.g. hidden preheader). */
Expand All @@ -8,18 +9,19 @@ export interface BodyProps {
/** `<footer>` fragment. */
footerHtml?: string;
/**
* Raw attributes for the opening `<body>` tag
* (for example `style="background-color:#fff;margin:0"`).
* Attributes for the opening `<body>` tag.
* Accepts a raw string or a structured map
* (for example `{ style: 'background-color:#fff;margin:0' }`).
* Callers own escaping. Empty when omitted.
*/
attributes?: string;
attributes?: TagAttributes;
}

export function body({
preheaderHtml = '',
mainHtml = '',
footerHtml = '',
attributes = '',
attributes,
}: BodyProps = {}): string {
const open = openTag('body', attributes);
return `${open}
Expand Down
28 changes: 17 additions & 11 deletions src/templates/document/document.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { body } from '../body/body';
import type { BodyProps } from '../body/body';
import { openTag } from '../../utils/openTag';
import { openTag, serializeAttributes } from '../../utils/openTag';
import type { TagAttributes } from '../../utils/openTag';

/**
* Options that control the outer document shell.
Expand All @@ -19,17 +20,19 @@ export interface DocumentShellOptions {
*/
lang?: string | false | null;
/**
* Additional raw attributes for the `<html>` element
* (for example `xmlns="http://www.w3.org/1999/xhtml"`).
* Additional attributes for the `<html>` element.
* Accepts a raw string or a structured map
* (for example `{ xmlns: 'http://www.w3.org/1999/xhtml' }`).
* Placed after `lang` when both are present. Callers own escaping.
*/
htmlAttributes?: string;
htmlAttributes?: TagAttributes;
/**
* Additional raw attributes for the `<body>` element
* (for example `style="margin:0;padding:0"` or `class="email"`).
* Additional attributes for the `<body>` element.
* Accepts a raw string or a structured map
* (for example `{ style: 'margin:0;padding:0' }`).
* Callers own escaping.
*/
bodyAttributes?: string;
bodyAttributes?: TagAttributes;
}

export interface DocumentProps extends BodyProps, DocumentShellOptions {
Expand All @@ -50,17 +53,20 @@ export function document({
headHtml = '',
doctype = '<!DOCTYPE html>',
lang = 'en',
htmlAttributes = '',
bodyAttributes = '',
htmlAttributes,
bodyAttributes,
...rest
}: DocumentProps = {}): string {
const langPart = lang === false || lang === null || lang === '' ? '' : `lang="${lang}"`;

const htmlAttrParts = [langPart, htmlAttributes.trim()].filter(Boolean);
const htmlAttrParts = [langPart, serializeAttributes(htmlAttributes)].filter(Boolean);
const htmlAttrs = htmlAttrParts.join(' ');

const htmlOpen = openTag('html', htmlAttrs);
const bodyHtml = body({ ...rest, attributes: bodyAttributes });
const bodyHtml = body({
...rest,
...(bodyAttributes !== undefined ? { attributes: bodyAttributes } : {}),
});

return `${doctype}
${htmlOpen}
Expand Down
2 changes: 1 addition & 1 deletion src/templates/email/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function defineEmailTemplate(input: EmailTemplate): Template<EmailProps>
),
),
],
compose: (parts, ctx) => composeEmail(parts, ctx.props),
compose: (parts, ctx) => composeEmail(parts, ctx.props, input.shell),
});
}

Expand Down
36 changes: 24 additions & 12 deletions src/templates/email/shell.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { displayErrors } from '../../runtime/errors';
import { document } from '../document/document';
import type { DocumentShellOptions } from '../document/document';
import { hasSlot, slot } from '../../runtime/render';
import type { RenderContext, TemplatePart } from '../../runtime/types';
import type { EmailProps } from './types';
import { pickDefined } from '../../utils/pickDefined';

const SHELL_PARTS = new Set(['head', 'main', 'footer']);

Expand All @@ -26,10 +26,27 @@ export function shellPart(
};
}

/**
* Merge template-level shell defaults with per-render props.
* Props win when both supply a value.
*/
export function mergeShell(
defaults: DocumentShellOptions | undefined,
props: EmailProps,
): DocumentShellOptions {
const merged: DocumentShellOptions = { ...defaults };
if (props.doctype !== undefined) merged.doctype = props.doctype;
if (props.lang !== undefined) merged.lang = props.lang;
if (props.htmlAttributes !== undefined) merged.htmlAttributes = props.htmlAttributes;
if (props.bodyAttributes !== undefined) merged.bodyAttributes = props.bodyAttributes;
return merged;
}

/** Place custom part HTML inside `<body>`, after `main` and before `footer`. */
export function composeEmail(
parts: Readonly<Record<string, string>>,
props: EmailProps = {},
shellDefaults?: DocumentShellOptions,
): string {
// Read the shell before custom ids. Key order is the part list.
const headHtml = readPart(parts, 'head');
Expand All @@ -39,20 +56,15 @@ export function composeEmail(
const footerHtml = readPart(parts, 'footer');
const mainHtml = extraIds.length > 0 ? `${mainPiece}\n${extraHtml}` : mainPiece;

const { doctype, lang, htmlAttributes, bodyAttributes } = props;

const shell = pickDefined({
doctype,
htmlAttributes,
bodyAttributes,
...(typeof lang === 'string' ? { lang } : {}),
});
const shell = mergeShell(shellDefaults, props);

return document({
headHtml,
mainHtml,
footerHtml,
...shell,
...(lang === false || lang === null ? { lang } : {}),
...(shell.doctype !== undefined ? { doctype: shell.doctype } : {}),
...(shell.lang !== undefined ? { lang: shell.lang } : {}),
...(shell.htmlAttributes !== undefined ? { htmlAttributes: shell.htmlAttributes } : {}),
...(shell.bodyAttributes !== undefined ? { bodyAttributes: shell.bodyAttributes } : {}),
});
}
}
4 changes: 4 additions & 0 deletions src/templates/email/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ export interface EmailProps extends DocumentShellOptions {
* An email-shaped template. `parts` are custom steps inserted inside `<body>`
* after `main` and before `footer`. Built-in `head`, `main`, and `footer`
* stay in place. A slot with the same id replaces that built-in part.
*
* `shell` holds default document options for this template. Per-render
* `EmailProps` override them.
*/
export interface EmailTemplate {
id: string;
parts?: readonly TemplatePart<EmailProps>[];
shell?: DocumentShellOptions;
}
30 changes: 27 additions & 3 deletions src/utils/openTag.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
/**
* Build an opening tag with optional raw attributes.
* Raw attribute string or a structured attribute map.
* Values are interpolated raw (callers own escaping), matching package policy.
* Boolean `true` emits the bare attribute name; `false`/`null`/`undefined` are omitted.
*/
export type TagAttributes = string | Record<string, string | number | boolean | null | undefined>;

/**
* Build an opening tag with optional attributes.
* Attributes are interpolated raw (callers own escaping), matching package policy.
*/
export function openTag(name: string, attributes?: string): string {
const attr = attributes?.trim();
export function openTag(name: string, attributes?: TagAttributes): string {
const attr = serializeAttributes(attributes);
return attr ? `<${name} ${attr}>` : `<${name}>`;
}

/** Turn a raw string or attribute map into a single attribute string. */
export function serializeAttributes(attributes?: TagAttributes): string {
if (!attributes) return '';
if (typeof attributes === 'string') return attributes.trim();

const parts: string[] = [];
for (const [key, value] of Object.entries(attributes)) {
if (value === undefined || value === null || value === false) continue;
if (value === true) {
parts.push(key);
} else {
parts.push(`${key}="${String(value)}"`);
}
}
return parts.join(' ');
}
18 changes: 18 additions & 0 deletions test/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@ describe('renderEmail', () => {
expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
});

it('applies template-level shell defaults, with props winning', () => {
const template = defineEmailTemplate({
id: 'postmark-like',
shell: {
doctype:
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">',
lang: false,
htmlAttributes: { xmlns: 'http://www.w3.org/1999/xhtml' },
bodyAttributes: 'style="margin:0"',
},
});
const html = renderEmail(template, { title: 'Hi', bodyAttributes: 'style="padding:0"' }).html;
expect(html).toMatch(/^<!DOCTYPE html PUBLIC/);
expect(html).toContain('<html xmlns="http://www.w3.org/1999/xhtml">');
expect(html).not.toContain('lang=');
expect(html).toContain('<body style="padding:0">');
});

it('forwards document shell options from EmailProps', () => {
const html = renderEmail(
{ id: 'styled' },
Expand Down
11 changes: 11 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ describe('document', () => {
expect(html).toContain('<title>Postmark</title>');
});

it('accepts structured attributes on html and body', () => {
const html = document({
lang: false,
htmlAttributes: { xmlns: 'http://www.w3.org/1999/xhtml', class: 'email' },
bodyAttributes: { style: 'margin:0', 'data-theme': 'light' },
mainHtml: '<p>Hi</p>',
});
expect(html).toContain('<html xmlns="http://www.w3.org/1999/xhtml" class="email">');
expect(html).toContain('<body style="margin:0" data-theme="light">');
});

it('supports body attributes and omitting lang (good-news style)', () => {
const html = document({
lang: null,
Expand Down
Loading