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
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,20 @@ renderTemplate(page, { title: 'Hello' }, { slots: { extra: '<p>More</p>' } });

The email preset calls these. They stay public so a part, or a caller, can use them directly.

| Function | Role |
| --------------------------------------------------- | -------------------------------------------------------------------- |
| `head({ title?, preview? })` | `<head>` with charset, viewport, `<title>`, and a hidden preview div |
| `main({ heading?, bodyText?, ctaLabel?, ctaUrl? })` | `<main>` with an `<h1>`, a paragraph, and an optional button |
| `footer({ companyName?, unsubscribeUrl? })` | `<footer>` with the company name and an optional unsubscribe link |
| `body({ mainHtml?, footerHtml? })` | `<body>` around the main and footer fragments |
| `content({ content? })` | A `<div>` around raw HTML. The email shell does not call it |
| `document({ headHtml?, mainHtml?, footerHtml? })` | Doctype, `<html lang="en">`, head, and body |
| Function | Role |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `head({ title?, preview?, styles?, extraHead? })` | `<head>` with inbox meta, `<title>`, optional preheader, styles, extra HTML |
| `main({ heading?, bodyText?, ctaLabel?, ctaUrl?, innerHtml? })` | `<main>` with an `<h1>`, a paragraph, and an optional button, or raw `innerHtml` |
| `footer({ companyName?, unsubscribeUrl? })` | `<footer>` with the company name and an optional unsubscribe link |
| `body({ preheaderHtml?, mainHtml?, footerHtml? })` | `<body>` around an optional preheader, main, and footer |
| `content({ content? })` | A `<div>` around raw HTML. The email shell does not call it |
| `document({ headHtml?, mainHtml?, footerHtml? })` | Doctype, `<html lang="en">`, head, and body |

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.

`bodyText`, `content`, and slot strings may contain inline HTML. Callers own that markup.
`bodyText`, `content`, and slot strings may contain inline HTML. Callers own that markup. `escapeHtml` is exported for text and attribute positions; the shell does not call it.

Subpath exports: `@llazyemail/template-runtime-display/escape`, `/head`, `/body`, and `/main`.

## Scripts

Expand Down
20 changes: 0 additions & 20 deletions deepseek/body.ts.ts

This file was deleted.

28 changes: 0 additions & 28 deletions deepseek/head.ts.ts

This file was deleted.

4 changes: 0 additions & 4 deletions deepseek/index.ts.ts

This file was deleted.

10 changes: 0 additions & 10 deletions deepseek/main.ts.ts

This file was deleted.

12 changes: 0 additions & 12 deletions deepseek/package.json

This file was deleted.

43 changes: 42 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,46 @@
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./escape": {
"import": {
"types": "./dist/escape.d.ts",
"default": "./dist/escape.js"
},
"require": {
"types": "./dist/escape.d.cts",
"default": "./dist/escape.cjs"
}
},
"./head": {
"import": {
"types": "./dist/head.d.ts",
"default": "./dist/head.js"
},
"require": {
"types": "./dist/head.d.cts",
"default": "./dist/head.cjs"
}
},
"./body": {
"import": {
"types": "./dist/body.d.ts",
"default": "./dist/body.js"
},
"require": {
"types": "./dist/body.d.cts",
"default": "./dist/body.cjs"
}
},
"./main": {
"import": {
"types": "./dist/main.d.ts",
"default": "./dist/main.js"
},
"require": {
"types": "./dist/main.d.cts",
"default": "./dist/main.cjs"
}
}
},
"files": [
Expand Down Expand Up @@ -85,6 +125,7 @@
"runtime",
"debug",
"llazyemail",
"esm"
"esm",
"escape"
]
}
11 changes: 9 additions & 2 deletions src/body.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
export interface BodyProps {
/** Raw HTML for the top of `<body>` (e.g. hidden preheader). */
preheaderHtml?: string;
/** `<main>` fragment. */
mainHtml?: string;
/** `<footer>` fragment. */
footerHtml?: string;
}

export function body({ mainHtml = '', footerHtml = '' }: BodyProps = {}): string {
export function body({
preheaderHtml = '',
mainHtml = '',
footerHtml = '',
}: BodyProps = {}): string {
return `<body>
${preheaderHtml}
${mainHtml}
${footerHtml}
</body>`;
</body>`;
}
73 changes: 73 additions & 0 deletions src/escape.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { body, escapeHtml, head, main } from './index';

describe('escapeHtml', () => {
it('escapes text and attribute characters', () => {
expect(escapeHtml(`Tom & Jerry <script> "quote" 'tick'`)).toBe(
'Tom &amp; Jerry &lt;script&gt; &quot;quote&quot; &#39;tick&#39;',
);
});

it('leaves safe text unchanged', () => {
expect(escapeHtml('Weekly update')).toBe('Weekly update');
expect(escapeHtml('')).toBe('');
});

it('escapes ampersands before other entities', () => {
expect(escapeHtml('&lt;')).toBe('&amp;lt;');
});
});

describe('head updated shell', () => {
it('renders inbox meta, a preheader span, styles, and extra head html', () => {
const html = head({
title: 'Hi',
preview: 'peek',
styles: 'body{margin:0}',
extraHead: '<!--[if mso]><xml></xml><![endif]-->',
});
expect(html).toContain('name="x-apple-disable-message-reformatting"');
expect(html).toContain('name="color-scheme" content="light dark"');
expect(html).toContain('<title>Hi</title>');
expect(html).toContain('<span class="preheader">peek</span>');
expect(html).toContain('<style type="text/css" rel="stylesheet" media="all">body{margin:0}');
expect(html).toContain('<!--[if mso]><xml></xml><![endif]-->');
});

it('omits preview, styles, and extra head when they are absent', () => {
const html = head({ title: 'Only' });
expect(html).not.toContain('preheader');
expect(html).not.toContain('<style');
expect(html).toContain('<title>Only</title>');
});
});

describe('body preheader', () => {
it('places preheader html before main and footer', () => {
const html = body({
preheaderHtml: '<span class="preheader">peek</span>',
mainHtml: '<main></main>',
footerHtml: '<footer></footer>',
});
expect(html.indexOf('preheader')).toBeLessThan(html.indexOf('<main>'));
expect(html.indexOf('<main>')).toBeLessThan(html.indexOf('<footer>'));
});
});

describe('main innerHtml', () => {
it('renders raw inner html and skips structured fields', () => {
const html = main({
innerHtml: '<p>Raw</p>',
heading: 'Ignored',
ctaLabel: 'Go',
ctaUrl: 'https://x',
});
expect(html).toBe(`<main>\n<p>Raw</p>\n </main>`);
expect(html).not.toContain('Ignored');
expect(html).not.toContain('<a href=');
});

it('still renders the structured shell when innerHtml is omitted', () => {
expect(main({ heading: 'H', bodyText: 'B' })).toContain('<h1>H</h1>');
});
});
File renamed without changes.
27 changes: 19 additions & 8 deletions src/head.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
export interface HeadProps {
/** Text for `<title>`. Empty when omitted. */
title?: string;
/** Inbox preview text. Rendered as a hidden div inside `<head>`. */
/** Inbox preview text. Rendered as a hidden span at the top of `<head>`. */
preview?: string;
/** Raw CSS injected into a `<style>` block. Empty when omitted. */
styles?: string;
/** Raw HTML inserted verbatim at the end of `<head>`. Use for MSO comments, extra meta, etc. */
extraHead?: string;
}

export function head({ title, preview }: HeadProps = {}): string {
return `
<head>
<meta charset="utf-8" />
export function head({ title, preview, styles, extraHead }: HeadProps = {}): string {
const previewHtml = preview ? `<span class="preheader">${preview}</span>` : '';
const stylesHtml = styles
? `<style type="text/css" rel="stylesheet" media="all">${styles}\n </style>`
: '';
return `<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title || ''}</title>
<!-- preview text, hidden -->
<div style="display:none;max-height:0;overflow:hidden;">${preview || ''}</div>
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>${title ?? ''}</title>
${previewHtml}
${stylesHtml}
${extraHead ?? ''}
</head>`;
}
2 changes: 1 addition & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('head', () => {
it('interpolates title and preview', () => {
const html = head({ title: 'Hi', preview: 'peek' });
expect(html).toContain('<title>Hi</title>');
expect(html).toContain('>peek</div>');
expect(html).toContain('<span class="preheader">peek</span>');
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export { explain } from './runtime/explain';
export { defineEmailTemplate, renderEmail } from './email';
export type { EmailProps, EmailTemplate } from './email';

export { escapeHtml } from './escape';

export { head } from './head';
export type { HeadProps } from './head';

Expand Down
12 changes: 9 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
export interface MainProps {
/** `<h1>` text. */
/** `<h1>` text. Ignored when `innerHtml` is set. */
heading?: string;
/** Paragraph under the heading. May contain inline HTML. */
/** Paragraph under the heading. May contain inline HTML. Ignored when `innerHtml` is set. */
bodyText?: string;
/** Button label. The button is omitted unless `ctaUrl` is set too. */
ctaLabel?: string;
/** Button href. The button is omitted unless `ctaLabel` is set too. */
ctaUrl?: string;
/** Raw inner HTML of `<main>`. When set, structured fields are not rendered. */
innerHtml?: string;
}

export function main({ heading, bodyText, ctaLabel, ctaUrl }: MainProps = {}): string {
export function main({ heading, bodyText, ctaLabel, ctaUrl, innerHtml }: MainProps = {}): string {
if (innerHtml !== undefined) {
return `<main>\n${innerHtml}\n </main>`;
}

const cta =
ctaLabel && ctaUrl
? `<a href="${ctaUrl}" style="display:inline-block;padding:12px 20px;background:#111;color:#fff;text-decoration:none;border-radius:4px;">${ctaLabel}</a>`
Expand Down
Loading
Loading