UNPKG

@travetto/email-inky

Version:

Email Inky templating module

341 lines (293 loc) 10.3 kB
import { JSXElement } from '@travetto/email-inky/jsx-runtime'; import { RenderProvider, RenderState } from '../types'; import { RenderContext } from './context'; import { classStr, combinePropsToStr, getKids, isOfType, visit } from './common'; 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(([k, v]) => `${k}: ${v}`).join('; '); const allowedProps = new Set([ 'className', 'id', 'dir', 'name', 'src', 'alt', 'href', 'title', 'height', 'target', 'width', 'style', 'align', 'valign' ]); const propsToStr = combinePropsToStr.bind(null, allowedProps); const stdInline = async ({ recurse, el }: RenderState<JSXElement, RenderContext>): Promise<string> => `<${el.type} ${propsToStr(el.props)}>${await recurse()}</${el.type}>`; const std = async (state: RenderState<JSXElement, RenderContext>): Promise<string> => `${await stdInline(state)}\n`; const stdFull = async (state: RenderState<JSXElement, RenderContext>): Promise<string> => `\n${await stdInline(state)}\n`; export const Html: RenderProvider<RenderContext> = { finalize: async (html, context, isRoot = false) => { html = html .replace(/(<[/](?:a)>)([A-Za-z0-9$])/g, (_, tag, v) => `${tag} ${v}`) .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( [^>]*)?>/, t => `${t}\n${headerTop.join('\n')}`) .replace(/<body[^>]*>/, t => `${t}\n${bodyTop.join('\n')}`); // Allow tag suffixes/prefixes via comments html = final .replace(/\s*<!--\s*[$]:([^ -]+)\s*-->\s*(<\/[^>]+>)/g, (_, suf, tag) => `${tag}${suf}`) .replace(/(<[^\/][^>]+>)\s*<!--\s*[#]:([^ ]+)\s*-->\s*/g, (_, tag, pre) => `${pre}${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 (el) => `<table ${propsToStr(el.props)}><th></th></table>`, strong: stdInline, em: stdInline, p: stdFull, h1: stdFull, h2: stdFull, h3: stdFull, h4: stdFull, li: std, ol: stdFull, ul: stdFull, table: stdFull, thead: std, tr: std, td: std, th: std, tbody: std, center: std, img: stdInline, title: std, div: std, span: stdInline, small: stdInline, a: async ({ recurse, props }) => `<a ${propsToStr(props)}>${await recurse()}</a>`, InkyTemplate: c => c.recurse(), Title: async ({ recurse, el }) => `<title>${await recurse()}</title>`, Summary: async ({ recurse, el }) => `<span id="summary" style="${SUMMARY_STYLE}">${await recurse()}</span>`, Column: async ({ props, recurse, stack, el, context }): Promise<string> => { recurse(); let expander = ''; const parent = stack[stack.length - 1]; const sibs = getKids(parent).filter(x => isOfType(x, 'Column')); const colCount = sibs.length || 1; if (parent) { const elParent: (typeof parent) & { columnVisited?: boolean } = parent; if (!elParent.columnVisited) { elParent.columnVisited = true; if (sibs.length) { sibs[0].props.className = classStr(sibs[0].props.className ?? '', 'first'); sibs[sibs.length - 1].props.className = classStr(sibs[sibs.length - 1].props.className ?? '', 'last'); } } } else { props.className = classStr(props.className ?? '', 'first', 'last'); } // Check for sizes. If no attribute is provided, default to small-12. Divide evenly for large columns const smallSize = el.props.small ?? context.columnCount; const largeSize = el.props.large ?? el.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(el, (node) => { if (isOfType(node, '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 ${propsToStr(el.props, classes)}> <table> <tbody> <tr> <th>${await recurse()}</th>${expander} </tr> </tbody> </table> </th>`; }, HLine: async ({ props }) => ` <table ${propsToStr(props, ['h-line'])}> <tbody> <tr><th>&nbsp;</th></tr> </tbody> </table>`, Row: async ({ recurse, el }): Promise<string> => ` <table ${propsToStr(el.props, ['row'])}> <tbody> <tr>${await recurse()}</tr> </tbody> </table>`, Button: async ({ recurse, el, 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 ${propsToStr(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 = classStr(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 ${propsToStr(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" ${propsToStr(props, ['container'])}> <tbody> <tr><td>${await recurse()}</td></tr> </tbody> </table>`, BlockGrid: async ({ recurse, props }): Promise<string> => ` <table ${propsToStr(props, ['block-grid', props.up ? `up-${props.up}` : ''])}> <tbody> <tr>${await recurse()}</tr> </tbody> </table>`, Menu: async ({ recurse, el, props }): Promise<string> => { let hasItem = false; visit(el, (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 ${propsToStr(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 ${propsToStr(parentAttrs, ['menu-item'])}> <a ${propsToStr({ href, target })}>${await recurse()}</a> </th>`; }, Center: async ({ props, recurse, el }): Promise<string> => { for (const kid of getKids(el)) { Object.assign(kid.props, { align: 'center', className: classStr(kid.props.className, 'float-center') }); } visit(el, child => { if (isOfType(child, 'Item')) { child.props.className = classStr(child.props.className, 'float-center'); } return; }); return ` <center ${propsToStr(props)}> ${await recurse()} </center> `; }, Callout: async ({ recurse, el, props }): Promise<string> => { const innerProps: JSXElement['props'] = { className: props.className }; delete props.className; return ` <table ${propsToStr(props, ['callout'])}> <tbody> <tr> <th ${propsToStr(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 ${propsToStr(props, ['spacer', extraClass])}> <tbody> <tr> <td height="${size}px" style="font-size:${size}px;line-height:${size}px;">&nbsp;</td> </tr> </tbody> </table> `; const sm = props.small ?? undefined; const lg = props.large ?? undefined; if (sm || lg) { if (sm) { html.push(buildSpacer(sm, 'hide-for-large')); } if (lg) { html.push(buildSpacer(lg, 'show-for-large')); } } else { html.push(buildSpacer(props.size || 16)); } return html.join('\n'); }, Wrapper: async ({ recurse, el }) => ` <table align="center" ${propsToStr(el.props, ['wrapper'])}> <tbody> <tr> <td class="wrapper-inner"> ${await recurse()} </td> </tr> </tbody> </table>` };