212 lines
7.6 KiB
TypeScript
212 lines
7.6 KiB
TypeScript
/**
|
|
* This file was generated by @pikku/cli@0.12.54
|
|
*/
|
|
type EmailPrimitive = string | number | boolean | null | undefined
|
|
type EmailTemplateValue = EmailPrimitive | Record<string, unknown> | Array<unknown>
|
|
|
|
const EMAIL_THEME = {
|
|
"appName": "Pikku Starter",
|
|
"fonts": {
|
|
"body": "Inter, Arial, sans-serif"
|
|
},
|
|
"colors": {
|
|
"canvas": "#0b1020",
|
|
"surface": "#11182d",
|
|
"border": "#263252",
|
|
"text": "#f7f8fb",
|
|
"muted": "#a8b0c5",
|
|
"accent": "#7dd3fc",
|
|
"button": "#f59e0b",
|
|
"buttonText": "#111827"
|
|
}
|
|
} as const
|
|
const EMAIL_LOCALES = {
|
|
"en": {
|
|
"common": {
|
|
"footer": "If you did not create this account, you can safely ignore this email."
|
|
},
|
|
"confirmEmail": {
|
|
"subject": "Confirm your email for {{appName}}",
|
|
"preview": "Confirm your email address to finish setting up your account.",
|
|
"heading": "Confirm your email",
|
|
"intro": "Thanks for signing up for {{appName}}! Confirm {{email}} to finish setting up your account.",
|
|
"cta": "Confirm email",
|
|
"fallback": "If the button does not work, copy and paste this URL into your browser:",
|
|
"expiry": "This link expires in 24 hours."
|
|
}
|
|
}
|
|
} as const
|
|
const EMAIL_PARTIALS = {
|
|
"footer": "<p style=\"margin:32px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;\">\n {{t.common.footer}}\n</p>\n",
|
|
"layout": "<!doctype html>\n<html lang=\"{{locale}}\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{{subject}}</title>\n </head>\n <body style=\"margin:0;padding:0;background:{{theme.colors.canvas}};font-family:{{theme.fonts.body}};\">\n <div style=\"max-width:560px;margin:0 auto;padding:32px 16px;\">\n {{content}}\n </div>\n </body>\n</html>\n"
|
|
} as const
|
|
const EMAIL_TEMPLATES = {
|
|
"confirm-email": {
|
|
"html": "<p style=\"margin:0 0 12px;color:{{theme.colors.accent}};font-size:12px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;\">\n {{appName}}\n</p>\n<h1 style=\"margin:0 0 16px;color:{{theme.colors.text}};font-size:28px;line-height:1.1;\">\n {{t.confirmEmail.heading}}\n</h1>\n<p style=\"margin:0 0 24px;color:{{theme.colors.muted}};font-size:16px;line-height:1.6;\">\n {{t.confirmEmail.intro}}\n</p>\n<p style=\"margin:0 0 24px;\">\n <a\n href=\"{{confirmUrl}}\"\n style=\"display:inline-block;background:{{theme.colors.button}};color:{{theme.colors.buttonText}};text-decoration:none;padding:14px 22px;border-radius:999px;font-weight:700;\"\n >\n {{t.confirmEmail.cta}}\n </a>\n</p>\n<p style=\"margin:0 0 8px;color:{{theme.colors.text}};font-size:14px;line-height:1.6;\">\n {{t.confirmEmail.fallback}}\n</p>\n<p style=\"margin:0;color:{{theme.colors.muted}};font-size:14px;line-height:1.6;word-break:break-all;\">\n {{confirmUrl}}\n</p>\n<p style=\"margin:24px 0 0;color:{{theme.colors.muted}};font-size:13px;line-height:1.6;\">\n {{t.confirmEmail.expiry}}\n</p>\n{{> footer}}\n",
|
|
"subject": "{{t.confirmEmail.subject}}\n",
|
|
"text": "{{t.confirmEmail.heading}}\n\n{{t.confirmEmail.intro}}\n\n{{confirmUrl}}\n\n{{t.confirmEmail.expiry}}\n",
|
|
"variables": [
|
|
"appName",
|
|
"confirmUrl",
|
|
"email"
|
|
],
|
|
"hashes": {
|
|
"en": {
|
|
"contentHash": "fa3ef0d0ff652703fc0586c5c78b4c4820206a09bd47651c56a9f1c26a5d2cfd",
|
|
"htmlHash": "c4b9403e096b1f6c439e120f701bf8cbc6bdba9fdb8e4f21fbad524961343a39",
|
|
"subjectHash": "209d4ac15c8f7e70e14de04c81724beb00dac7f4ea450a6e8ce527faf4a0ad75",
|
|
"textHash": "4b494839407f2817290b9b08103cd84103e53a6339ff4e4f24e71dd89ebb7d13"
|
|
}
|
|
}
|
|
}
|
|
} as const
|
|
|
|
export type EmailTemplateName = keyof typeof EMAIL_TEMPLATES
|
|
export type EmailLocale = keyof typeof EMAIL_LOCALES
|
|
|
|
type TemplateVariableMap = {
|
|
"confirm-email": {
|
|
"appName"?: EmailTemplateValue
|
|
"confirmUrl"?: EmailTemplateValue
|
|
"email"?: EmailTemplateValue
|
|
}
|
|
}
|
|
|
|
export type EmailTemplateVariables<TName extends EmailTemplateName> =
|
|
TemplateVariableMap[TName]
|
|
|
|
export type RenderEmailInput<TName extends EmailTemplateName> = {
|
|
name: TName
|
|
locale?: EmailLocale
|
|
data: EmailTemplateVariables<TName>
|
|
}
|
|
|
|
export type RenderedEmail<TName extends EmailTemplateName> = {
|
|
name: TName
|
|
locale: EmailLocale
|
|
subject: string
|
|
html: string
|
|
text?: string
|
|
variables: ReadonlyArray<string>
|
|
hash: (typeof EMAIL_TEMPLATES)[TName]['hashes'][EmailLocale]['contentHash']
|
|
}
|
|
|
|
function getNestedValue(source: Record<string, unknown>, path: string): string {
|
|
const segments = path.split('.')
|
|
let current: unknown = source
|
|
for (const segment of segments) {
|
|
if (!current || typeof current !== 'object' || !(segment in current)) {
|
|
return ''
|
|
}
|
|
current = (current as Record<string, unknown>)[segment]
|
|
}
|
|
return typeof current === 'string' || typeof current === 'number'
|
|
? String(current)
|
|
: ''
|
|
}
|
|
|
|
function applyTemplate(source: string, context: Record<string, unknown>): string {
|
|
return source.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, rawKey) => {
|
|
const key = String(rawKey).trim()
|
|
if (key === 'content') {
|
|
return typeof context.content === 'string' ? context.content : ''
|
|
}
|
|
if (key.startsWith('>')) {
|
|
return ''
|
|
}
|
|
return getNestedValue(context, key)
|
|
})
|
|
}
|
|
|
|
function renderTemplate(source: string, context: Record<string, unknown>): string {
|
|
let rendered = source
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const next = applyTemplate(rendered, context)
|
|
if (next === rendered) break
|
|
rendered = next
|
|
}
|
|
return rendered
|
|
}
|
|
|
|
function renderPartial(name: string, context: Record<string, unknown>): string {
|
|
const partial = EMAIL_PARTIALS[name as keyof typeof EMAIL_PARTIALS]
|
|
return partial ? renderTemplate(partial, context) : ''
|
|
}
|
|
|
|
export const EMAILS = EMAIL_TEMPLATES
|
|
|
|
export function renderEmailTemplate<TName extends EmailTemplateName>(
|
|
input: RenderEmailInput<TName>
|
|
): RenderedEmail<TName> {
|
|
const locale = (input.locale ?? 'en') as EmailLocale
|
|
const template = EMAIL_TEMPLATES[input.name]
|
|
if (!template) {
|
|
throw new Error(`Unknown email template: ${String(input.name)}`)
|
|
}
|
|
|
|
const strings = EMAIL_LOCALES[locale]
|
|
if (!strings) {
|
|
throw new Error(`Unknown email locale: ${String(locale)}`)
|
|
}
|
|
|
|
const data = (input.data ?? {}) as Record<string, unknown>
|
|
const appName =
|
|
(typeof data.appName === 'string' && data.appName) ||
|
|
getNestedValue(EMAIL_THEME as Record<string, unknown>, 'appName')
|
|
|
|
const baseContext = {
|
|
...data,
|
|
locale,
|
|
theme: EMAIL_THEME,
|
|
t: strings,
|
|
appName,
|
|
}
|
|
|
|
const subject = renderTemplate(template.subject, baseContext).trim()
|
|
const htmlWithPartials = template.html.replace(
|
|
/\{\{\s*>\s*([a-zA-Z0-9-_/.]+)\s*\}\}/g,
|
|
(_match, partialName) => `@@PARTIAL:${String(partialName).trim()}@@`
|
|
)
|
|
|
|
let htmlBody = renderTemplate(htmlWithPartials, {
|
|
...baseContext,
|
|
subject,
|
|
})
|
|
|
|
const partialMatches = [...htmlBody.matchAll(/@@PARTIAL:([^@]+)@@/g)]
|
|
for (const match of partialMatches) {
|
|
const rendered = renderPartial(match[1], {
|
|
...baseContext,
|
|
subject,
|
|
})
|
|
htmlBody = htmlBody.replace(match[0], rendered)
|
|
}
|
|
|
|
const html = EMAIL_PARTIALS.layout
|
|
? renderTemplate(EMAIL_PARTIALS.layout, {
|
|
...baseContext,
|
|
subject,
|
|
content: htmlBody,
|
|
})
|
|
: htmlBody
|
|
|
|
const text = template.text
|
|
? renderTemplate(template.text, {
|
|
...baseContext,
|
|
subject,
|
|
}).trim()
|
|
: undefined
|
|
const hash = (template.hashes[locale]?.contentHash ??
|
|
'') as RenderedEmail<TName>['hash']
|
|
|
|
return {
|
|
name: input.name,
|
|
locale,
|
|
subject,
|
|
html,
|
|
...(text ? { text } : {}),
|
|
variables: template.variables,
|
|
hash,
|
|
}
|
|
}
|