From f987f3f8c9de1f3a3fdc9be0f504e2d39d48ba00 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 26 Mar 2026 14:55:12 -0500 Subject: [PATCH] intial state --- .github/workflows/release.yml | 68 ++++++++++ .gitignore | 4 + Containerfile | 19 +++ README.md | 75 +++++++++++ dist/cli.d.ts | 2 + dist/cli.js | 20 +++ dist/constants.d.ts | 7 + dist/constants.js | 6 + dist/index.d.ts | 14 ++ dist/index.js | 12 ++ dist/lib/jsx.d.ts | 28 ++++ dist/lib/jsx.js | 96 +++++++++++++ dist/templates/failure.d.ts | 4 + dist/templates/failure.js | 13 ++ dist/templates/pipeline-status.d.ts | 11 ++ dist/templates/pipeline-status.js | 141 +++++++++++++++++++ dist/templates/success.d.ts | 4 + dist/templates/success.js | 13 ++ package.json | 32 +++++ podman-compose.yml | 12 ++ src/cli.ts | 38 ++++++ src/constants.ts | 9 ++ src/index.ts | 28 ++++ src/lib/jsx.ts | 160 ++++++++++++++++++++++ src/node-shims.d.ts | 26 ++++ src/templates/failure.tsx | 21 +++ src/templates/pipeline-status.tsx | 202 ++++++++++++++++++++++++++++ src/templates/success.tsx | 21 +++ tsconfig.json | 19 +++ 29 files changed, 1105 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 README.md create mode 100644 dist/cli.d.ts create mode 100644 dist/cli.js create mode 100644 dist/constants.d.ts create mode 100644 dist/constants.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/lib/jsx.d.ts create mode 100644 dist/lib/jsx.js create mode 100644 dist/templates/failure.d.ts create mode 100644 dist/templates/failure.js create mode 100644 dist/templates/pipeline-status.d.ts create mode 100644 dist/templates/pipeline-status.js create mode 100644 dist/templates/success.d.ts create mode 100644 dist/templates/success.js create mode 100644 package.json create mode 100644 podman-compose.yml create mode 100644 src/cli.ts create mode 100644 src/constants.ts create mode 100644 src/index.ts create mode 100644 src/lib/jsx.ts create mode 100644 src/node-shims.d.ts create mode 100644 src/templates/failure.tsx create mode 100644 src/templates/pipeline-status.tsx create mode 100644 src/templates/success.tsx create mode 100644 tsconfig.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a53a622 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Build And Release Templates + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Podman + run: | + sudo apt-get update + sudo apt-get install -y podman + + - name: Build production image + run: podman build --target runtime -t ci-cd-email-templates:${{ github.sha }} . + + - name: Render HTML templates + run: | + mkdir -p build/html + podman run --rm \ + -e OUTPUT_DIR=/workspace/build/html \ + -v "${GITHUB_WORKSPACE}/build/html:/workspace/build/html" \ + ci-cd-email-templates:${{ github.sha }} + + - name: Upload rendered templates artifact + uses: actions/upload-artifact@v4 + with: + name: rendered-email-templates + path: build/html/*.html + if-no-files-found: error + + release: + runs-on: ubuntu-latest + needs: build + permissions: + contents: read + steps: + - name: Download rendered templates + uses: actions/download-artifact@v4 + with: + name: rendered-email-templates + path: release/html + + - name: Upload templates to Cloudflare R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: true + CF_R2_ACCOUNT_ID: ${{ secrets.CF_R2_ACCOUNT_ID }} + CF_R2_BUCKET: ${{ secrets.CF_R2_BUCKET }} + CF_R2_PREFIX: ${{ vars.CF_R2_PREFIX }} + run: | + ENDPOINT="https://${CF_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + TARGET="s3://${CF_R2_BUCKET}" + if [ -n "${CF_R2_PREFIX}" ]; then + TARGET="${TARGET}/${CF_R2_PREFIX}" + fi + aws s3 sync release/html "${TARGET}" --endpoint-url "${ENDPOINT}" --delete diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abe4dd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +.DS_Store +npm-debug.log* diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..9b54a19 --- /dev/null +++ b/Containerfile @@ -0,0 +1,19 @@ +FROM docker.io/library/node:24-bookworm AS builder + +WORKDIR /workspace + +COPY package.json tsconfig.json ./ +RUN npm install + +COPY src ./src +RUN npm run build + +FROM docker.io/library/node:24-bookworm-slim AS runtime + +WORKDIR /workspace +ENV NODE_ENV=production + +COPY package.json ./ +COPY --from=builder /workspace/dist ./dist + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffda48f --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# CI/CD Email Templates + +TSX-based email templates for CI/CD pipeline success and failure notifications. The package renders minified HTML for both templates, writes them to disk, and emits the rendered templates on stdout as JSON for pipeline automation. + +## Placeholder + +The application name placeholder is always `{{APP_NAME}}`. + +Pipeline logic can either: + +- replace `{{APP_NAME}}` in the uploaded HTML before sending, or +- set `APP_NAME` when running the renderer to emit app-specific HTML directly. + +## Local usage + +Build the package: + +```bash +npm install +npm run build +``` + +Render the templates: + +```bash +npm start +``` + +By default this writes: + +- `build/html/pipeline-success.html` +- `build/html/pipeline-failure.html` + +Override the application name or output directory with environment variables: + +```bash +APP_NAME=my-service OUTPUT_DIR=./out npm start +``` + +## Podman lifecycle + +Build and run through `podman-compose`: + +```bash +podman-compose up --build +``` + +The rendered HTML files are written to `./build/html` on the host. + +Override the rendered app name during compose runs: + +```bash +APP_NAME=my-service podman-compose up --build +``` + +If `APP_NAME` is not set, the renderer falls back to `{{APP_NAME}}`. + +## GitHub Actions release + +The workflow at `.github/workflows/release.yml`: + +1. builds the production container image with Podman +2. runs the renderer to produce HTML files +3. uploads the generated templates to a Cloudflare R2 bucket + +Required GitHub secrets: + +- `CF_R2_ACCESS_KEY_ID` +- `CF_R2_SECRET_ACCESS_KEY` +- `CF_R2_ACCOUNT_ID` +- `CF_R2_BUCKET` + +Optional GitHub variable: + +- `CF_R2_PREFIX` diff --git a/dist/cli.d.ts b/dist/cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/dist/cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..bc45eea --- /dev/null +++ b/dist/cli.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES, renderAllTemplates } from "./index.js"; +async function main() { + const appName = process.env.APP_NAME?.trim() || APP_NAME_PLACEHOLDER; + const outputDir = resolve(process.cwd(), process.env.OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR); + const templates = renderAllTemplates({ appName }); + await mkdir(outputDir, { recursive: true }); + await Promise.all([ + writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.success), templates.success, "utf8"), + writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.failure), templates.failure, "utf8") + ]); + process.stdout.write(`${JSON.stringify({ + appName, + outputDir, + templates + }, null, 2)}\n`); +} +await main(); diff --git a/dist/constants.d.ts b/dist/constants.d.ts new file mode 100644 index 0000000..40910b7 --- /dev/null +++ b/dist/constants.d.ts @@ -0,0 +1,7 @@ +export declare const APP_NAME_PLACEHOLDER = "{{APP_NAME}}"; +export declare const DEFAULT_OUTPUT_DIR = "build/html"; +export declare const TEMPLATE_FILE_NAMES: { + readonly success: "pipeline-success.html"; + readonly failure: "pipeline-failure.html"; +}; +export type TemplateName = keyof typeof TEMPLATE_FILE_NAMES; diff --git a/dist/constants.js b/dist/constants.js new file mode 100644 index 0000000..c963678 --- /dev/null +++ b/dist/constants.js @@ -0,0 +1,6 @@ +export const APP_NAME_PLACEHOLDER = "{{APP_NAME}}"; +export const DEFAULT_OUTPUT_DIR = "build/html"; +export const TEMPLATE_FILE_NAMES = { + success: "pipeline-success.html", + failure: "pipeline-failure.html" +}; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..aacea29 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,14 @@ +export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js"; +export type { TemplateName } from "./constants.js"; +export type { FailureTemplateOptions } from "./templates/failure.js"; +export type { SuccessTemplateOptions } from "./templates/success.js"; +export { renderFailureTemplate } from "./templates/failure.js"; +export { renderSuccessTemplate } from "./templates/success.js"; +export interface RenderAllTemplatesOptions { + appName?: string; +} +export interface RenderedTemplates { + success: string; + failure: string; +} +export declare function renderAllTemplates({ appName }?: RenderAllTemplatesOptions): RenderedTemplates; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..df606a5 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,12 @@ +import { APP_NAME_PLACEHOLDER } from "./constants.js"; +import { renderFailureTemplate } from "./templates/failure.js"; +import { renderSuccessTemplate } from "./templates/success.js"; +export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js"; +export { renderFailureTemplate } from "./templates/failure.js"; +export { renderSuccessTemplate } from "./templates/success.js"; +export function renderAllTemplates({ appName = APP_NAME_PLACEHOLDER } = {}) { + return { + success: renderSuccessTemplate({ appName }), + failure: renderFailureTemplate({ appName }) + }; +} diff --git a/dist/lib/jsx.d.ts b/dist/lib/jsx.d.ts new file mode 100644 index 0000000..257e539 --- /dev/null +++ b/dist/lib/jsx.d.ts @@ -0,0 +1,28 @@ +export type StyleValue = string | Record; +export type Child = HtmlNode | string | number | boolean | null | undefined | Child[]; +export type Props = Record & { + children?: Child[]; + style?: StyleValue; +}; +export type Component

> = (props: P & { + children?: Child[]; +}) => Child; +export interface HtmlElement { + type: string; + props: Props; + children: Child[]; +} +export declare const Fragment: unique symbol; +type NodeType = string | typeof Fragment | Component; +export type HtmlNode = HtmlElement; +export declare function h(type: NodeType, props: Props | null, ...children: Child[]): Child; +export declare function renderEmail(node: Child): string; +declare global { + namespace JSX { + type Element = Child; + interface IntrinsicElements { + [elementName: string]: Record; + } + } +} +export {}; diff --git a/dist/lib/jsx.js b/dist/lib/jsx.js new file mode 100644 index 0000000..cf625f9 --- /dev/null +++ b/dist/lib/jsx.js @@ -0,0 +1,96 @@ +export const Fragment = Symbol.for("email-template.fragment"); +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr" +]); +export function h(type, props, ...children) { + const normalizedProps = props ?? {}; + if (type === Fragment) { + return children; + } + if (typeof type === "function") { + return type({ + ...normalizedProps, + children + }); + } + return { + type, + props: normalizedProps, + children + }; +} +export function renderEmail(node) { + return minifyHtml(`${renderNode(node)}`); +} +function renderNode(node) { + if (node === null || node === undefined || typeof node === "boolean") { + return ""; + } + if (Array.isArray(node)) { + return node.map((child) => renderNode(child)).join(""); + } + if (typeof node === "string" || typeof node === "number") { + return escapeHtml(String(node)); + } + const attributes = renderAttributes(node.props); + if (VOID_ELEMENTS.has(node.type)) { + return `<${node.type}${attributes}>`; + } + const content = node.children.map((child) => renderNode(child)).join(""); + return `<${node.type}${attributes}>${content}`; +} +function renderAttributes(props) { + return Object.entries(props) + .filter(([key, value]) => key !== "children" && value !== null && value !== undefined && value !== false) + .map(([key, value]) => { + const attrName = key === "className" ? "class" : key; + if (value === true) { + return ` ${attrName}`; + } + if (attrName === "style") { + return ` style="${escapeAttribute(serializeStyle(value))}"`; + } + return ` ${attrName}="${escapeAttribute(String(value))}"`; + }) + .join(""); +} +function serializeStyle(style) { + if (typeof style === "string") { + return style.trim(); + } + return Object.entries(style) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `${toKebabCase(key)}:${String(value)}`) + .join(";"); +} +function toKebabCase(value) { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} +function escapeAttribute(value) { + return escapeHtml(value).replaceAll("\"", """); +} +function minifyHtml(html) { + return html + .replace(/>\s+<") + .replace(/\s{2,}/g, " ") + .trim(); +} diff --git a/dist/templates/failure.d.ts b/dist/templates/failure.d.ts new file mode 100644 index 0000000..8fe071e --- /dev/null +++ b/dist/templates/failure.d.ts @@ -0,0 +1,4 @@ +export interface FailureTemplateOptions { + appName?: string; +} +export declare function renderFailureTemplate({ appName }?: FailureTemplateOptions): string; diff --git a/dist/templates/failure.js b/dist/templates/failure.js new file mode 100644 index 0000000..f945450 --- /dev/null +++ b/dist/templates/failure.js @@ -0,0 +1,13 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { renderPipelineStatusTemplate } from "./pipeline-status.js"; +export function renderFailureTemplate({ appName = APP_NAME_PLACEHOLDER } = {}) { + return renderPipelineStatusTemplate({ + appName, + bannerLabel: "Failure", + heading: "Pipeline failed before completion", + summary: "failed before it could complete.", + details: "Review the job logs, identify the failing stage, and rerun the pipeline after the issue has been corrected.", + accentColor: "#b91c1c", + accentTint: "#fee2e2" + }); +} diff --git a/dist/templates/pipeline-status.d.ts b/dist/templates/pipeline-status.d.ts new file mode 100644 index 0000000..0a7519a --- /dev/null +++ b/dist/templates/pipeline-status.d.ts @@ -0,0 +1,11 @@ +export interface PipelineStatusTemplateProps { + appName?: string; + bannerLabel: string; + heading: string; + summary: string; + details: string; + accentColor: string; + accentTint: string; +} +export declare function PipelineStatusTemplate({ appName, bannerLabel, heading, summary, details, accentColor, accentTint }: PipelineStatusTemplateProps): import("../lib/jsx.js").Child; +export declare function renderPipelineStatusTemplate(props: PipelineStatusTemplateProps): string; diff --git a/dist/templates/pipeline-status.js b/dist/templates/pipeline-status.js new file mode 100644 index 0000000..0d93645 --- /dev/null +++ b/dist/templates/pipeline-status.js @@ -0,0 +1,141 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { h, renderEmail } from "../lib/jsx.js"; +const bodyStyle = { + margin: 0, + padding: 0, + backgroundColor: "#f3f4f6", + fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif", + color: "#111827" +}; +const shellStyle = { + width: "100%", + backgroundColor: "#f3f4f6", + padding: "32px 16px" +}; +const cardStyle = { + width: "100%", + maxWidth: "640px", + backgroundColor: "#ffffff", + borderCollapse: "collapse", + border: "1px solid #e5e7eb" +}; +const contentStyle = { + padding: "32px" +}; +const badgeStyle = (accentColor, accentTint) => ({ + display: "inline-block", + padding: "8px 12px", + borderRadius: "999px", + backgroundColor: accentTint, + color: accentColor, + fontSize: "12px", + fontWeight: 700, + letterSpacing: "0.08em", + textTransform: "uppercase" +}); +const headingStyle = { + margin: "20px 0 12px", + fontSize: "28px", + lineHeight: 1.25, + color: "#111827" +}; +const copyStyle = { + margin: "0 0 16px", + fontSize: "16px", + lineHeight: 1.65, + color: "#374151" +}; +const panelStyle = (accentColor) => ({ + marginTop: "28px", + border: `1px solid ${accentColor}`, + borderRadius: "12px", + overflow: "hidden" +}); +const panelHeaderStyle = (accentColor) => ({ + padding: "14px 18px", + backgroundColor: accentColor, + color: "#ffffff", + fontSize: "14px", + fontWeight: 700, + letterSpacing: "0.05em", + textTransform: "uppercase" +}); +const panelBodyStyle = { + padding: "18px", + backgroundColor: "#ffffff" +}; +const labelStyle = { + width: "140px", + padding: "0 0 12px", + fontSize: "13px", + lineHeight: 1.5, + color: "#6b7280", + verticalAlign: "top" +}; +const valueStyle = { + padding: "0 0 12px", + fontSize: "15px", + lineHeight: 1.6, + color: "#111827", + verticalAlign: "top" +}; +const footerStyle = { + padding: "20px 32px 32px", + fontSize: "13px", + lineHeight: 1.6, + color: "#6b7280" +}; +function PreviewText({ text }) { + return (h("div", { style: { + display: "none", + overflow: "hidden", + lineHeight: 1, + opacity: 0, + maxHeight: 0, + maxWidth: 0, + msoHide: "all" + } }, text)); +} +export function PipelineStatusTemplate({ appName = APP_NAME_PLACEHOLDER, bannerLabel, heading, summary, details, accentColor, accentTint }) { + const resolvedAppName = appName.trim() || APP_NAME_PLACEHOLDER; + const previewText = `${resolvedAppName} ${summary}`; + return (h("html", { lang: "en" }, + h("head", null, + h("meta", { charset: "utf-8" }), + h("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0" }), + h("title", null, heading)), + h("body", { style: bodyStyle }, + h(PreviewText, { text: previewText }), + h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: shellStyle }, + h("tr", null, + h("td", { align: "center" }, + h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: cardStyle }, + h("tr", null, + h("td", { style: contentStyle }, + h("span", { style: badgeStyle(accentColor, accentTint) }, bannerLabel), + h("h1", { style: headingStyle }, heading), + h("p", { style: copyStyle }, + "The CI/CD pipeline for ", + h("strong", null, resolvedAppName), + " ", + summary), + h("p", { style: copyStyle }, details), + h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0", style: panelStyle(accentColor) }, + h("tr", null, + h("td", { style: panelHeaderStyle(accentColor) }, "Pipeline Summary")), + h("tr", null, + h("td", { style: panelBodyStyle }, + h("table", { role: "presentation", width: "100%", cellpadding: "0", cellspacing: "0" }, + h("tr", null, + h("td", { style: labelStyle }, "Application"), + h("td", { style: valueStyle }, + h("strong", null, resolvedAppName))), + h("tr", null, + h("td", { style: labelStyle }, "Status"), + h("td", { style: valueStyle }, bannerLabel)))))))), + h("tr", null, + h("td", { style: footerStyle }, "This message was generated from the CI/CD notification template package."))))))))); +} +export function renderPipelineStatusTemplate(props) { + return renderEmail(h(PipelineStatusTemplate, { ...props })); +} diff --git a/dist/templates/success.d.ts b/dist/templates/success.d.ts new file mode 100644 index 0000000..6fefd10 --- /dev/null +++ b/dist/templates/success.d.ts @@ -0,0 +1,4 @@ +export interface SuccessTemplateOptions { + appName?: string; +} +export declare function renderSuccessTemplate({ appName }?: SuccessTemplateOptions): string; diff --git a/dist/templates/success.js b/dist/templates/success.js new file mode 100644 index 0000000..7bf1f56 --- /dev/null +++ b/dist/templates/success.js @@ -0,0 +1,13 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { renderPipelineStatusTemplate } from "./pipeline-status.js"; +export function renderSuccessTemplate({ appName = APP_NAME_PLACEHOLDER } = {}) { + return renderPipelineStatusTemplate({ + appName, + bannerLabel: "Success", + heading: "Pipeline completed successfully", + summary: "completed successfully and is ready for the next step.", + details: "No blocking failures were detected during this run. Continue with the normal rollout, deployment, or stakeholder notification flow.", + accentColor: "#15803d", + accentTint: "#dcfce7" + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4103711 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "ci-cd-email-templates", + "version": "0.1.0", + "description": "TSX email templates for CI/CD pipeline success and failure notifications.", + "type": "module", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "ci-cd-email-templates": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "render": "node dist/cli.js", + "start": "node dist/cli.js" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..0d3ef47 --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,12 @@ +services: + templates: + build: + context: . + dockerfile: Containerfile + target: runtime + image: localhost/ci-cd-email-templates:latest + environment: + APP_NAME: + OUTPUT_DIR: /workspace/build/html + volumes: + - ./build/html:/workspace/build/html:Z diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..daf5c56 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { + APP_NAME_PLACEHOLDER, + DEFAULT_OUTPUT_DIR, + TEMPLATE_FILE_NAMES, + renderAllTemplates +} from "./index.js"; + +async function main(): Promise { + const appName = process.env.APP_NAME?.trim() || APP_NAME_PLACEHOLDER; + const outputDir = resolve(process.cwd(), process.env.OUTPUT_DIR?.trim() || DEFAULT_OUTPUT_DIR); + const templates = renderAllTemplates({ appName }); + + await mkdir(outputDir, { recursive: true }); + + await Promise.all([ + writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.success), templates.success, "utf8"), + writeFile(resolve(outputDir, TEMPLATE_FILE_NAMES.failure), templates.failure, "utf8") + ]); + + process.stdout.write( + `${JSON.stringify( + { + appName, + outputDir, + templates + }, + null, + 2 + )}\n` + ); +} + +await main(); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c4966d9 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,9 @@ +export const APP_NAME_PLACEHOLDER = "{{APP_NAME}}"; +export const DEFAULT_OUTPUT_DIR = "build/html"; + +export const TEMPLATE_FILE_NAMES = { + success: "pipeline-success.html", + failure: "pipeline-failure.html" +} as const; + +export type TemplateName = keyof typeof TEMPLATE_FILE_NAMES; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..71ef44b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,28 @@ +import { APP_NAME_PLACEHOLDER } from "./constants.js"; +import { renderFailureTemplate } from "./templates/failure.js"; +import { renderSuccessTemplate } from "./templates/success.js"; + +export { APP_NAME_PLACEHOLDER, DEFAULT_OUTPUT_DIR, TEMPLATE_FILE_NAMES } from "./constants.js"; +export type { TemplateName } from "./constants.js"; +export type { FailureTemplateOptions } from "./templates/failure.js"; +export type { SuccessTemplateOptions } from "./templates/success.js"; +export { renderFailureTemplate } from "./templates/failure.js"; +export { renderSuccessTemplate } from "./templates/success.js"; + +export interface RenderAllTemplatesOptions { + appName?: string; +} + +export interface RenderedTemplates { + success: string; + failure: string; +} + +export function renderAllTemplates({ + appName = APP_NAME_PLACEHOLDER +}: RenderAllTemplatesOptions = {}): RenderedTemplates { + return { + success: renderSuccessTemplate({ appName }), + failure: renderFailureTemplate({ appName }) + }; +} diff --git a/src/lib/jsx.ts b/src/lib/jsx.ts new file mode 100644 index 0000000..60bde8b --- /dev/null +++ b/src/lib/jsx.ts @@ -0,0 +1,160 @@ +export type StyleValue = + | string + | Record; + +export type Child = + | HtmlNode + | string + | number + | boolean + | null + | undefined + | Child[]; + +export type Props = Record & { + children?: Child[]; + style?: StyleValue; +}; + +export type Component

> = ( + props: P & { children?: Child[] } +) => Child; + +export interface HtmlElement { + type: string; + props: Props; + children: Child[]; +} + +export const Fragment = Symbol.for("email-template.fragment"); + +type NodeType = string | typeof Fragment | Component; + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr" +]); + +export type HtmlNode = HtmlElement; + +export function h(type: NodeType, props: Props | null, ...children: Child[]): Child { + const normalizedProps = props ?? {}; + + if (type === Fragment) { + return children; + } + + if (typeof type === "function") { + return type({ + ...normalizedProps, + children + }); + } + + return { + type, + props: normalizedProps, + children + }; +} + +export function renderEmail(node: Child): string { + return minifyHtml(`${renderNode(node)}`); +} + +function renderNode(node: Child): string { + if (node === null || node === undefined || typeof node === "boolean") { + return ""; + } + + if (Array.isArray(node)) { + return node.map((child) => renderNode(child)).join(""); + } + + if (typeof node === "string" || typeof node === "number") { + return escapeHtml(String(node)); + } + + const attributes = renderAttributes(node.props); + + if (VOID_ELEMENTS.has(node.type)) { + return `<${node.type}${attributes}>`; + } + + const content = node.children.map((child) => renderNode(child)).join(""); + return `<${node.type}${attributes}>${content}`; +} + +function renderAttributes(props: Props): string { + return Object.entries(props) + .filter(([key, value]) => key !== "children" && value !== null && value !== undefined && value !== false) + .map(([key, value]) => { + const attrName = key === "className" ? "class" : key; + + if (value === true) { + return ` ${attrName}`; + } + + if (attrName === "style") { + return ` style="${escapeAttribute(serializeStyle(value as StyleValue))}"`; + } + + return ` ${attrName}="${escapeAttribute(String(value))}"`; + }) + .join(""); +} + +function serializeStyle(style: StyleValue): string { + if (typeof style === "string") { + return style.trim(); + } + + return Object.entries(style) + .filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `${toKebabCase(key)}:${String(value)}`) + .join(";"); +} + +function toKebabCase(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replaceAll("\"", """); +} + +function minifyHtml(html: string): string { + return html + .replace(/>\s+<") + .replace(/\s{2,}/g, " ") + .trim(); +} + +declare global { + namespace JSX { + type Element = Child; + + interface IntrinsicElements { + [elementName: string]: Record; + } + } +} diff --git a/src/node-shims.d.ts b/src/node-shims.d.ts new file mode 100644 index 0000000..f00a1f3 --- /dev/null +++ b/src/node-shims.d.ts @@ -0,0 +1,26 @@ +declare module "node:fs/promises" { + export function mkdir( + path: string, + options?: { + recursive?: boolean; + } + ): Promise; + + export function writeFile( + file: string, + data: string, + encoding?: string + ): Promise; +} + +declare module "node:path" { + export function resolve(...paths: string[]): string; +} + +declare const process: { + cwd(): string; + env: Record; + stdout: { + write(chunk: string): boolean; + }; +}; diff --git a/src/templates/failure.tsx b/src/templates/failure.tsx new file mode 100644 index 0000000..8a7765e --- /dev/null +++ b/src/templates/failure.tsx @@ -0,0 +1,21 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { renderPipelineStatusTemplate } from "./pipeline-status.js"; + +export interface FailureTemplateOptions { + appName?: string; +} + +export function renderFailureTemplate({ + appName = APP_NAME_PLACEHOLDER +}: FailureTemplateOptions = {}): string { + return renderPipelineStatusTemplate({ + appName, + bannerLabel: "Failure", + heading: "Pipeline failed before completion", + summary: "failed before it could complete.", + details: + "Review the job logs, identify the failing stage, and rerun the pipeline after the issue has been corrected.", + accentColor: "#b91c1c", + accentTint: "#fee2e2" + }); +} diff --git a/src/templates/pipeline-status.tsx b/src/templates/pipeline-status.tsx new file mode 100644 index 0000000..5a9734e --- /dev/null +++ b/src/templates/pipeline-status.tsx @@ -0,0 +1,202 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { Fragment, h, renderEmail } from "../lib/jsx.js"; + +export interface PipelineStatusTemplateProps { + appName?: string; + bannerLabel: string; + heading: string; + summary: string; + details: string; + accentColor: string; + accentTint: string; +} + +const bodyStyle = { + margin: 0, + padding: 0, + backgroundColor: "#f3f4f6", + fontFamily: "Arial, 'Helvetica Neue', Helvetica, sans-serif", + color: "#111827" +}; + +const shellStyle = { + width: "100%", + backgroundColor: "#f3f4f6", + padding: "32px 16px" +}; + +const cardStyle = { + width: "100%", + maxWidth: "640px", + backgroundColor: "#ffffff", + borderCollapse: "collapse", + border: "1px solid #e5e7eb" +}; + +const contentStyle = { + padding: "32px" +}; + +const badgeStyle = (accentColor: string, accentTint: string) => ({ + display: "inline-block", + padding: "8px 12px", + borderRadius: "999px", + backgroundColor: accentTint, + color: accentColor, + fontSize: "12px", + fontWeight: 700, + letterSpacing: "0.08em", + textTransform: "uppercase" +}); + +const headingStyle = { + margin: "20px 0 12px", + fontSize: "28px", + lineHeight: 1.25, + color: "#111827" +}; + +const copyStyle = { + margin: "0 0 16px", + fontSize: "16px", + lineHeight: 1.65, + color: "#374151" +}; + +const panelStyle = (accentColor: string) => ({ + marginTop: "28px", + border: `1px solid ${accentColor}`, + borderRadius: "12px", + overflow: "hidden" +}); + +const panelHeaderStyle = (accentColor: string) => ({ + padding: "14px 18px", + backgroundColor: accentColor, + color: "#ffffff", + fontSize: "14px", + fontWeight: 700, + letterSpacing: "0.05em", + textTransform: "uppercase" +}); + +const panelBodyStyle = { + padding: "18px", + backgroundColor: "#ffffff" +}; + +const labelStyle = { + width: "140px", + padding: "0 0 12px", + fontSize: "13px", + lineHeight: 1.5, + color: "#6b7280", + verticalAlign: "top" +}; + +const valueStyle = { + padding: "0 0 12px", + fontSize: "15px", + lineHeight: 1.6, + color: "#111827", + verticalAlign: "top" +}; + +const footerStyle = { + padding: "20px 32px 32px", + fontSize: "13px", + lineHeight: 1.6, + color: "#6b7280" +}; + +function PreviewText({ text }: { text: string }) { + return ( +

+ {text} +
+ ); +} + +export function PipelineStatusTemplate({ + appName = APP_NAME_PLACEHOLDER, + bannerLabel, + heading, + summary, + details, + accentColor, + accentTint +}: PipelineStatusTemplateProps) { + const resolvedAppName = appName.trim() || APP_NAME_PLACEHOLDER; + const previewText = `${resolvedAppName} ${summary}`; + + return ( + + + + + {heading} + + + + + + + +
+ + + + + + + +
+ {bannerLabel} +

{heading}

+

+ The CI/CD pipeline for {resolvedAppName} {summary} +

+

{details}

+ + + + + + + +
Pipeline Summary
+ + + + + + + + + +
Application + {resolvedAppName} +
Status{bannerLabel}
+
+
+ This message was generated from the CI/CD notification template package. +
+
+ + + ); +} + +export function renderPipelineStatusTemplate(props: PipelineStatusTemplateProps): string { + return renderEmail(); +} diff --git a/src/templates/success.tsx b/src/templates/success.tsx new file mode 100644 index 0000000..8a9b66b --- /dev/null +++ b/src/templates/success.tsx @@ -0,0 +1,21 @@ +import { APP_NAME_PLACEHOLDER } from "../constants.js"; +import { renderPipelineStatusTemplate } from "./pipeline-status.js"; + +export interface SuccessTemplateOptions { + appName?: string; +} + +export function renderSuccessTemplate({ + appName = APP_NAME_PLACEHOLDER +}: SuccessTemplateOptions = {}): string { + return renderPipelineStatusTemplate({ + appName, + bannerLabel: "Success", + heading: "Pipeline completed successfully", + summary: "completed successfully and is ready for the next step.", + details: + "No blocking failures were detected during this run. Continue with the normal rollout, deployment, or stakeholder notification flow.", + accentColor: "#15803d", + accentTint: "#dcfce7" + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..500e67a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "declaration": true, + "sourceMap": false, + "skipLibCheck": true, + "jsx": "react", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment" + }, + "include": [ + "src/**/*" + ] +}