@travetto/email-inky
Version:
Email Inky templating module
340 lines (293 loc) • 10.7 kB
text/typescript
import type { JSXElement } from '../../support/jsx-runtime.ts';
import type { RenderProvider, RenderState } from '../types.ts';
import type { RenderContext } from './context.ts';
import { classString, combinePropsToString, getChildren, isOfType, visit } from './common.ts';
export const SUMMARY_STYLE = Object.entries({
display: 'none',
'font-size': '1px',
color: '#333333',
'line-height': '1px',
'max-height': '0px',
'max-width': '0px',
opacity: '0',
overflow: 'hidden'
}).map(([key, value]) => `${key}: ${value}`).join('; ');
const allowedProps = new Set([
'className', 'id', 'dir', 'name', 'src',
'alt', 'href', 'title', 'height', 'target',
'width', 'style', 'align', 'valign'
]);
const propsToString = combinePropsToString.bind(null, allowedProps);
const standardInline = async ({ recurse, node }: RenderState<JSXElement, RenderContext>): Promise<string> =>
`<${node.type} ${propsToString(node.props)}>${await recurse()}</${node.type}>`;
const standard = async (state: RenderState<JSXElement, RenderContext>): Promise<string> => `${await standardInline(state)}\n`;
const standardFull = async (state: RenderState<JSXElement, RenderContext>): Promise<string> => `\n${await standardInline(state)}\n`;
export const Html: RenderProvider<RenderContext> = {
finalize: async (html, context, isRoot = false) => {
html = html
.replace(/(<[/](?:a)>)([A-Za-z0-9$])/g, (_, tag, value) => `${tag} ${value}`)
.replace(/(<[uo]l>)(<li>)/g, (_, a, b) => `${a} ${b}`);
if (isRoot) {
const wrapper = await context.loader.read('/email/inky.wrapper.html');
// Get Subject
const headerTop: string[] = [];
const bodyTop: string[] = [];
// Force summary to top, and title to head
const final = wrapper
.replace('<!-- BODY -->', html)
.replace(/<title>.*?<\/title>/, a => { headerTop.push(a); return ''; })
.replace(/<span[^>]+id="summary"[^>]*>(.*?)<\/span>/sm, a => { bodyTop.push(a); return ''; })
.replace(/<head( [^>]*)?>/, tag => `${tag}\n${headerTop.join('\n')}`)
.replace(/<body[^>]*>/, tag => `${tag}\n${bodyTop.join('\n')}`);
// Allow tag suffixes/prefixes via comments
html = final
.replace(/\s*<!--\s*[$]:([^ -]+)\s*-->\s*(<\/[^>]+>)/g, (_, suffix, tag) => `${tag}${suffix}`)
.replace(/(<[^\/][^>]+>)\s*<!--\s*[#]:([^ ]+)\s*-->\s*/g, (_, tag, prefix) => `${prefix}${tag}`);
}
return html;
},
For: async ({ recurse, props }) => `{{#${props.attr}}}${await recurse()}{{/${props.attr}}}`,
If: async ({ recurse, props }) => `{{#${props.attr}}}${await recurse()}{{/${props.attr}}}`,
Unless: async ({ recurse, props }) => `{{^${props.attr}}}${await recurse()}{{/${props.attr}}}`,
Value: async ({ props }) => props.raw ? `{{{${props.attr}}}}` : `{{${props.attr}}}`,
br: async () => '<br>\n',
hr: async (node) => `<table ${propsToString(node.props)}><th></th></table>`,
strong: standardInline, em: standardInline, p: standardFull,
h1: standardFull, h2: standardFull, h3: standardFull, h4: standardFull,
li: standard, ol: standardFull, ul: standardFull,
table: standardFull, thead: standard, tr: standard, td: standard, th: standard, tbody: standard, center: standard, img: standardInline,
title: standard,
div: standard, span: standardInline, small: standardInline,
a: async ({ recurse, props }) => `<a ${propsToString(props)}>${await recurse()}</a>`,
InkyTemplate: ctx => ctx.recurse(),
Title: async ({ recurse }) => `<title>${await recurse()}</title>`,
Summary: async ({ recurse }) => `<span id="summary" style="${SUMMARY_STYLE}">${await recurse()}</span>`,
Column: async ({ props, recurse, stack, node, context }): Promise<string> => {
recurse();
let expander = '';
const parent = stack.at(-1)!;
const siblings = getChildren(parent).filter(child => isOfType(child, 'Column'));
const colCount = siblings.length || 1;
if (parent) {
const parentNode: (typeof parent) & { columnVisited?: boolean } = parent;
if (!parentNode.columnVisited) {
parentNode.columnVisited = true;
if (siblings.length) {
siblings[0].props.className = classString(siblings[0].props.className ?? '', 'first');
siblings.at(-1)!.props.className = classString(siblings.at(-1)!.props.className ?? '', 'last');
}
}
} else {
props.className = classString(props.className ?? '', 'first', 'last');
}
// Check for sizes. If no attribute is provided, default to small-12. Divide evenly for large columns
const smallSize = node.props.small ?? context.columnCount;
const largeSize = node.props.large ?? node.props.small ?? Math.trunc(context.columnCount / colCount);
// If the column contains a nested row, the .expander class should not be used
if (largeSize === context.columnCount && !props.noExpander) {
let hasRow = false;
visit(node, (child) => {
if (isOfType(child, 'Row')) {
return hasRow = true;
}
});
if (!hasRow) {
expander = '\n<th class="expander"></th>';
}
}
const classes: string[] = [`small-${smallSize}`, `large-${largeSize}`, 'columns'];
if (props.smallOffset) {
classes.push(`small-offset-${props.smallOffset}`);
}
if (props.hideSmall) {
classes.push('hide-for-small');
}
if (props.largeOffset) {
classes.push(`large-offset-${props.largeOffset}`);
}
if (props.hideLarge) {
classes.push('hide-for-large');
}
// Final HTML output
return `
<th ${propsToString(node.props, classes)}>
<table>
<tbody>
<tr>
<th>${await recurse()}</th>${expander}
</tr>
</tbody>
</table>
</th>`;
},
HLine: async ({ props }) => `
<table ${propsToString(props, ['h-line'])}>
<tbody>
<tr><th> </th></tr>
</tbody>
</table>`,
Row: async ({ recurse, node }): Promise<string> => `
<table ${propsToString(node.props, ['row'])}>
<tbody>
<tr>${await recurse()}</tr>
</tbody>
</table>`,
Button: async ({ recurse, props, createState }): Promise<string> => {
const { href, target, ...rest } = props;
let inner = await recurse();
let expander = '';
// If we have the href attribute we can create an anchor for the inner of the button;
if (href) {
const linkProps = { href, target };
if (props.expanded) {
Object.assign(linkProps, { align: 'center', className: 'float-center' });
}
inner = `<a ${propsToString(linkProps)}>${inner}</a>`;
}
// If the button is expanded, it needs a <center> tag around the content
if (props.expanded) {
inner = await Html.Center(createState('Center', { children: [inner] }));
rest.className = classString(rest.className ?? '', 'expand');
expander = '\n<td class="expander"></td>';
}
// The .button class is always there, along with any others on the <button> element
return `
<table ${propsToString(rest, ['button'])}>
<tbody>
<tr>
<td>
<table>
<tbody>
<tr>
<td>
${inner}
</td>
</tr>
</tbody>
</table>
</td>${expander}
</tr>
</tbody>
</table>
${await Html.Spacer(createState('Spacer', { size: 16 }))}`;
},
Container: async ({ recurse, props }): Promise<string> => `
<table align="center" ${propsToString(props, ['container'])}>
<tbody>
<tr><td>${await recurse()}</td></tr>
</tbody>
</table>`,
BlockGrid: async ({ recurse, props }): Promise<string> => `
<table ${propsToString(props, ['block-grid', props.up ? `up-${props.up}` : ''])}>
<tbody>
<tr>${await recurse()}</tr>
</tbody>
</table>`,
Menu: async ({ recurse, node, props }): Promise<string> => {
let hasItem = false;
visit(node, (child) => {
if (isOfType(child, 'Item')) {
return hasItem = true;
} else if ((child.type === 'td' || child.type === 'th') && child.props.className?.includes('menu-item')) {
return hasItem = true;
}
});
let inner = await recurse();
if (!hasItem && inner) {
inner = `<th class="menu-item">${inner}</th>`;
}
return `
<table ${propsToString(props, ['menu'])}>
<tbody>
<tr>
<td>
<table>
<tbody>
<tr>
${inner}
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>`;
},
Item: async ({ recurse, props }): Promise<string> => {
const { href, target, ...parentAttrs } = props;
return `
<th ${propsToString(parentAttrs, ['menu-item'])}>
<a ${propsToString({ href, target })}>${await recurse()}</a>
</th>`;
},
Center: async ({ props, recurse, node }): Promise<string> => {
for (const child of getChildren(node)) {
Object.assign(child.props, {
align: 'center',
className: classString(child.props.className, 'float-center')
});
}
visit(node, child => {
if (isOfType(child, 'Item')) {
child.props.className = classString(child.props.className, 'float-center');
}
return;
});
return `
<center ${propsToString(props)}>
${await recurse()}
</center>
`;
},
Callout: async ({ recurse, props }): Promise<string> => {
const innerProps: JSXElement['props'] = { className: props.className };
delete props.className;
return `
<table ${propsToString(props, ['callout'])}>
<tbody>
<tr>
<th ${propsToString(innerProps, ['callout-inner'])}>
${await recurse()}
</th>
<th class="expander"></th>
</tr>
</tbody>
</table>`;
},
Spacer: async ({ props }): Promise<string> => {
const html: string[] = [];
const buildSpacer = (size: number | string, extraClass: string = ''): string =>
`
<table ${propsToString(props, ['spacer', extraClass])}>
<tbody>
<tr>
<td height="${size}px" style="font-size:${size}px;line-height:${size}px;"> </td>
</tr>
</tbody>
</table>
`;
const small = props.small ?? undefined;
const large = props.large ?? undefined;
if (small || large) {
if (small) {
html.push(buildSpacer(small, 'hide-for-large'));
}
if (large) {
html.push(buildSpacer(large, 'show-for-large'));
}
} else {
html.push(buildSpacer(props.size || 16));
}
return html.join('\n');
},
Wrapper: async ({ recurse, node }) => `
<table align="center" ${propsToString(node.props, ['wrapper'])}>
<tbody>
<tr>
<td class="wrapper-inner">
${await recurse()}
</td>
</tr>
</tbody>
</table>`
};