intial state
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@@ -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"]
|
||||
@@ -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`
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
export {};
|
||||
Vendored
+20
@@ -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();
|
||||
Vendored
+7
@@ -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;
|
||||
Vendored
+6
@@ -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"
|
||||
};
|
||||
Vendored
+14
@@ -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;
|
||||
Vendored
+12
@@ -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 })
|
||||
};
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
export type StyleValue = string | Record<string, string | number | null | undefined>;
|
||||
export type Child = HtmlNode | string | number | boolean | null | undefined | Child[];
|
||||
export type Props = Record<string, unknown> & {
|
||||
children?: Child[];
|
||||
style?: StyleValue;
|
||||
};
|
||||
export type Component<P = Record<string, unknown>> = (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<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
export {};
|
||||
Vendored
+96
@@ -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(`<!DOCTYPE html>${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}</${node.type}>`;
|
||||
}
|
||||
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+</g, "><")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export interface FailureTemplateOptions {
|
||||
appName?: string;
|
||||
}
|
||||
export declare function renderFailureTemplate({ appName }?: FailureTemplateOptions): string;
|
||||
Vendored
+13
@@ -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"
|
||||
});
|
||||
}
|
||||
Vendored
+11
@@ -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;
|
||||
Vendored
+141
@@ -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 }));
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export interface SuccessTemplateOptions {
|
||||
appName?: string;
|
||||
}
|
||||
export declare function renderSuccessTemplate({ appName }?: SuccessTemplateOptions): string;
|
||||
Vendored
+13
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+38
@@ -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<void> {
|
||||
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();
|
||||
@@ -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;
|
||||
@@ -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 })
|
||||
};
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
export type StyleValue =
|
||||
| string
|
||||
| Record<string, string | number | null | undefined>;
|
||||
|
||||
export type Child =
|
||||
| HtmlNode
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| Child[];
|
||||
|
||||
export type Props = Record<string, unknown> & {
|
||||
children?: Child[];
|
||||
style?: StyleValue;
|
||||
};
|
||||
|
||||
export type Component<P = Record<string, unknown>> = (
|
||||
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(`<!DOCTYPE html>${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}</${node.type}>`;
|
||||
}
|
||||
|
||||
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+</g, "><")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = Child;
|
||||
|
||||
interface IntrinsicElements {
|
||||
[elementName: string]: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
declare module "node:fs/promises" {
|
||||
export function mkdir(
|
||||
path: string,
|
||||
options?: {
|
||||
recursive?: boolean;
|
||||
}
|
||||
): Promise<string | undefined>;
|
||||
|
||||
export function writeFile(
|
||||
file: string,
|
||||
data: string,
|
||||
encoding?: string
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
declare module "node:path" {
|
||||
export function resolve(...paths: string[]): string;
|
||||
}
|
||||
|
||||
declare const process: {
|
||||
cwd(): string;
|
||||
env: Record<string, string | undefined>;
|
||||
stdout: {
|
||||
write(chunk: string): boolean;
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: "none",
|
||||
overflow: "hidden",
|
||||
lineHeight: 1,
|
||||
opacity: 0,
|
||||
maxHeight: 0,
|
||||
maxWidth: 0,
|
||||
msoHide: "all"
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{heading}</title>
|
||||
</head>
|
||||
<body style={bodyStyle}>
|
||||
<PreviewText text={previewText} />
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={shellStyle}>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={cardStyle}>
|
||||
<tr>
|
||||
<td style={contentStyle}>
|
||||
<span style={badgeStyle(accentColor, accentTint)}>{bannerLabel}</span>
|
||||
<h1 style={headingStyle}>{heading}</h1>
|
||||
<p style={copyStyle}>
|
||||
The CI/CD pipeline for <strong>{resolvedAppName}</strong> {summary}
|
||||
</p>
|
||||
<p style={copyStyle}>{details}</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style={panelStyle(accentColor)}>
|
||||
<tr>
|
||||
<td style={panelHeaderStyle(accentColor)}>Pipeline Summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={panelBodyStyle}>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style={labelStyle}>Application</td>
|
||||
<td style={valueStyle}>
|
||||
<strong>{resolvedAppName}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={labelStyle}>Status</td>
|
||||
<td style={valueStyle}>{bannerLabel}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={footerStyle}>
|
||||
This message was generated from the CI/CD notification template package.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderPipelineStatusTemplate(props: PipelineStatusTemplateProps): string {
|
||||
return renderEmail(<PipelineStatusTemplate {...props} />);
|
||||
}
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user