Plain-JavaScript functions for generating production-ready HTML email templates — built for the Hackernoon newsletter pipeline and the broader
markdown-to-emailproject.
- Overview
- Features
- Architecture & How It Works
- Tech Stack
- Repository Structure
- Prerequisites
- Installation & Setup
- Configuration
- Usage
- Workflow
- Development
- Testing & Validation
- Building & Bundling
- Deployment & Publishing
- Troubleshooting / FAQ
- Security & Privacy Notes
- Contributing
- Roadmap
- Related Resources & Articles
- Recent Changes
- About the
outerTemplateModule - Architecture Decisions
- Legacy README
- License
- Acknowledgements
hn_email_template is a modular JavaScript library that assembles complete, production-grade HTML email templates from small, composable function-based components.
HTML email markup is notoriously fragile. It relies on HTML4-era table-based layouts, inline styles, and a maze of compatibility quirks across email clients. When the Hackernoon engineering team needed to automate newsletter generation in the markdown-to-email pipeline, maintaining monolithic template strings became unsustainable.
This project solves that by:
- Breaking a single large HTML template into small, independently-testable function components
- Separating outer structure (head, body wrapper, footer) from inner content (articles, sponsor blocks, etc.)
- Providing a clear display pipeline so each section can be rendered and previewed in isolation
- Gradually migrating toward a TypeScript / NX monorepo structure for long-term maintainability
| Feature | Details |
|---|---|
| Composable components | Each email section (head, body, footer, main, content) is a standalone JS function |
| Display pipeline | A runDisplayPipeline abstraction maps raw data → validated model → rendered HTML string |
| Input validation | Structured validation rules throw descriptive errors on missing required fields |
| Multiple output formats | Bundled as CJS, ES module, and IIFE via Rollup |
| Jest test suite | 13 unit tests + integration tests covering every component and display section |
| ESLint + Prettier | Enforced code style with pre-commit hooks via Husky and lint-staged |
| Renovate bot | Automated dependency update PRs |
| NX / TypeScript workspace | Active TypeScript migration in the hackernoon/ directory |
| Gitpod ready | One-click cloud development environment via .gitpod.yml |
The template is split into two conceptual layers:
┌─────────────────────────────────────────────────────────┐
│ Full Email Template │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Head │ │ Main │ │ Body │ │ Footer │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────┘ │
│ Outer Template │
├─────────────────────────────────────────────────────────┤
│ Inner Content │
│ ┌─────────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Articles │ │ Sponsors │ │ Preview Text │ │
│ └─────────────┘ └──────────┘ └───────────────────┘ │
└─────────────────────────────────────────────────────────┘
Each section follows the same three-step pipeline defined in Work/src/display/core/:
Raw Input Data
│
▼
mapper.js ← maps raw input to a normalised internal structure
│
▼
model.js ← validates the structure; throws on missing required fields
│
▼
display.js ← renders the HTML string from the validated model
│
▼
HTML String
createDisplaySection.js wraps this three-step pattern, and runDisplayPipeline.js orchestrates running it across all sections.
renderTemplate('hn', { string, data }) in sub-modules/outerTemplate/src/templates/hn.js is the primary public API. It accepts a content string (or a { string, data } object) and wraps it in the full Hackernoon outer template, producing a ready-to-send HTML email string.
| Tool / Library | Role |
|---|---|
| Node.js | Runtime |
| Rollup | Module bundler (CJS, ES, IIFE outputs) |
| Babel | ES2015+ transpilation |
| Jest | Unit and integration testing |
| ESLint | Static analysis |
| Prettier | Code formatting |
| Husky + lint-staged | Pre-commit hooks |
| Renovate | Automated dependency updates |
| NX | Monorepo tooling (TypeScript migration workspace) |
| Lodash | Utility helpers |
| atherdon-newsletter-js-layouts-* | Published npm packages for body, typography, misc layouts |
| email-template-object | Shared email template object model |
| html-typography-tags | HTML typography helpers |
Note:
Work/is the current runtime root and is transitional/deprecated. New top-level directories (content/,scripts/,src/,tests/,generated/,docs/) have been introduced as part of the reorganization. See Target Architecture anddocs/architecture.md.
hn_email_template/
├── .deepsource.toml # DeepSource code quality config
├── .gitignore
├── .gitpod.yml # Gitpod cloud dev environment config
├── package-lock.json # Root lockfile
├── README.md # This file
├── README.old.md # Preserved legacy README (historical reference)
│
├── .github/
│ └── workflows/
│ ├── codeql-analysis.yml # CodeQL security scanning
│ ├── eslint.yml # ESLint CI check
│ ├── node.js.yml # Node.js CI (build & test)
│ └── npm-publish.yml # NPM publish workflow
│
├── content/ # All newsletter content datasets (new canonical location)
│ ├── content1.js # Canonical template dataset
│ ├── content2.js # HN JSON-authored variant
│ ├── content3.js # Markdown-derived variant
│ └── data-markdown.js # Body content blocks array
│
├── scripts/ # Generation scripts (new canonical location)
│ └── generate-template.js # Top-level template generator
│
├── src/ # Rendering/business logic (migration target from Work/src/)
│ └── README.md # See for migration status
│
├── tests/ # Test suites (migration target from Work/tests/)
│ └── README.md # See for migration status
│
├── generated/ # Generated HTML outputs (gitignored)
│
├── docs/ # Project documentation & ADRs
│ ├── README.md
│ └── architecture.md # Target architecture & baseline behavior
│
├── Work/ # ⚠️ DEPRECATED transitional runtime root
│ │ # Do not add new core logic here. Will be removed
│ │ # once the top-level migration is complete.
│ ├── package.json # Package metadata & scripts (v3.9.0)
│ ├── jest.config.js # Jest configuration
│ ├── rollup.config.js # Rollup bundler configuration
│ ├── renovate.json # Renovate bot config
│ │
│ ├── bash/
│ │ ├── lint-fix.sh # Run ESLint auto-fix
│ │ ├── tests.sh # Run test suite
│ │ └── update-packages.sh # Update npm packages
│ │
│ ├── scripts/
│ │ └── generate-template.js # ⚠️ deprecated — use scripts/ at project root
│ │
│ ├── src/
│ │ ├── index.js # Public API exports
│ │ ├── config.js # Shared constants (contact URL, mailing address)
│ │ ├── data-markdown.js # Body content from content-from-markdown.html (lines 30–225)
│ │ ├── factory.js # Display factory class
│ │ ├── methods.js # Top-level print* helper functions
│ │ │
│ │ ├── components/ # Low-level HTML component functions
│ │ │
│ │ ├── templates/ # Template rendering system
│ │ │
│ │ └── display/ # Display pipeline (mapper → model → display)
│ │
│ └── tests/
│ ├── unit/ # Unit test files (one per component/section)
│ └── integration/ # End-to-end and integration tests
│
├── files/ # ⚠️ DEPRECATED — re-exports to content/ (backward compat)
│ ├── data.js # → re-exports content/content1.js
│ ├── data-hn.js # → re-exports content/content2.js
│ └── data-from-markdown.js # → re-exports content/content3.js
│
├── sub-modules/ # Standalone reusable sub-packages
│ ├── Typography/ # Typography HTML rendering module
│ ├── innerComponents/ # Inner email components module
│ ├── Miscellaneous/ # Miscellaneous utilities
│ └── outerTemplate/ # Outer template module
│
├── packages/ # Published npm packages
│ ├── template-engine/
│ ├── template-presets-hn/
│ └── template-runtime-display/
│
└── archive/ # Archived legacy files (historical reference)
See
docs/architecture.mdfor the full migration plan.
The project is being reorganized toward a clean top-level structure:
| Directory | Purpose |
|---|---|
content/ |
All newsletter content datasets (canonical location) |
src/ |
Rendering and business logic (migrating from Work/src/) |
scripts/ |
Generation and tooling scripts (migrated from Work/scripts/) |
tests/ |
All test suites (migrating from Work/tests/) |
generated/ |
Generated HTML outputs (gitignored) |
docs/ |
Project documentation and ADRs |
Work/ will remain as a transitional runtime root until the migration is complete,
then be removed. No new core logic should be added to Work/.
Before the reorganization, generation used files/data-hn.js as the default
content source and output to Work/generated/. These paths are still valid
(via backward-compat re-exports in files/), but the canonical commands are now:
cd Work
# Generate using the canonical HN dataset (content2):
npm run generate:template -- --data=../content/content2.js --out=generated/hn.html
# Generate using the markdown-derived dataset (content3):
npm run generate:template -- \
--data=../content/content3.js \
--content=src/content-from-markdown.html \
--out=generated/hn-markdown.htmlOr using the new top-level script from the project root:
node scripts/generate-template.js --data=content/content2.js --out=generated/hn.htmlRun tests to verify behavior is unchanged:
cd Work && npm test- Node.js >= 18 (18.x or 20.x; these are the versions tested in CI)
- npm >= 7 (workspaces support)
- Git
The NX workspace under
hackernoon/additionally requires TypeScript >= 4.9 (installed as a dev dependency).
git clone https://github.com/LLazyEmail/hn_email_template.git
cd hn_email_templatecd Work
npm installcd hackernoon
npm installOpen the repo in gitpod.io:
https://gitpod.io/#https://github.com/LLazyEmail/hn_email_template
The .gitpod.yml is pre-configured to install dependencies and start the dev watcher automatically.
All shared constants live in Work/src/config.js:
export const config = {
contact: 'https://sponsor.hackernoon.com/newsletter?ref=noonifications.tech',
mailingAddress: 'PO Box 2206, Edwards CO, 81632, U.S.A.',
unsubscribe: '#', // Set to your real unsubscribe URL in production
};There are no required environment variables for local development. If you extend the project (e.g., to send emails or integrate with an API), consider adding a .env file and using a library like dotenv — but make sure to add it to .gitignore and never commit secrets.
import { renderTemplate } from 'atherdon-old-newsletter-js-outertemplate';
// or from local source:
// import { renderTemplate } from 'atherdon-newsletter-js-layouts-outertemplate';
// Render with a plain HTML string as body content
const html = renderTemplate('hn', '<p>Hello, world!</p>');
// Render with structured data
const html = renderTemplate('hn', {
string: '<p>Hello, world!</p>',
data: { title: 'My Newsletter Issue #1' },
});
console.log(html); // Full HTML email string ready to sendimport { printMain, printFooter, printBody } from 'atherdon-old-newsletter-js-outertemplate';
const mainHtml = printMain(); // Renders the main outer wrapper
const footerHtml = printFooter(); // Renders the footer section
const bodyHtml = printBody(); // Renders the body sectionThe display pipeline throws descriptive errors on invalid input, for example:
// Missing required `title`
// → Error: `title` is a required option for `renderTemplate`
// Missing required `bodyContent`
// → Error: `bodyContent` is a required option for `renderTemplate`content/data-markdown.js (previously Work/src/data-markdown.js) contains the
newsletter body content extracted from Work/src/content-from-markdown.html
(lines 30–225) as an ordered JavaScript array of typed blocks. Each block has a
type field ("heading", "image", or "text") plus type-specific fields
(html, src/link/alt). The array preserves the original document order
and can be used for rendering comparison, content inspection, or as a data source
for custom renderers.
To generate a full email template using the markdown-derived data and content:
cd Work
npm run generate:template -- \
--data=../content/content3.js \
--content=src/content-from-markdown.html \
--out=generated/hn-markdown.htmlThe intended end-to-end workflow for the Hackernoon newsletter pipeline is:
Markdown article content
│
▼
markdown-to-email (sibling repository)
│ converts Markdown → HTML inner content string
▼
renderTemplate('hn', { string: innerContentHtml })
│ wraps inner content in the full outer template
▼
Full HTML email string
│
▼
Email service provider (Mailchimp, SendGrid, etc.)
The separation between inner content and outer template means that the two parts can evolve independently, and the outer template can be tested without any real article content.
cd Work
npm run devThis starts Rollup in watch mode — any change to a source file triggers an incremental rebuild.
# Check for lint errors
npm run lint
# Auto-fix lint errors
npm run lint:fixOr use the convenience shell scripts:
bash bash/lint-fix.sh# Check formatting
npm run format:check
# Apply formatting
npm run formatHusky and lint-staged are configured to run ESLint + Prettier automatically on every git commit for files under src/**/*.js.
Tests live in Work/tests/ and are split into unit and integration suites.
cd Work
npm test
# or
bash bash/tests.shnpm run test:unitnpm run test:integrationnpm run test:template
# runs tests/integration/template.test.js| Directory | Contents |
|---|---|
tests/unit/ |
13 files — one per component / display section |
tests/integration/ |
End-to-end template rendering test |
Key unit test files:
mainComponent.unit.test.jsheadComponent.unit.test.jsbody.unit.test.jsfooter.unit.test.jsdisplayHead.unit.test.js,displayBody.unit.test.js,displayFooter.unit.test.js,displayMain.unit.test.js,displayContent.unit.test.jstemplates.unit.test.jsvalidation.unit.test.jscreateDisplaySection.unit.test.js
The repository ships a small set of content fixtures under Work/fixtures/ — JSON files that each represent a different newsletter payload. Running the fixture generator renders the same template for every fixture and writes one HTML file per fixture to Work/generated/fixtures/.
| Fixture | Description |
|---|---|
default.json |
Full payload matching the default src/data.js (title, preview, ads, images) |
minimal.json |
Bare-minimum payload — title and preview only, no ads or images |
no-images.json |
Ads included but no images — validates layout with a missing images array |
cd Work
npm run generate:fixtures
# outputs: generated/fixtures/default.html minimal.html no-images.htmlOpen any of the generated files in a browser and compare side-by-side.
- Create a new JSON file in
Work/fixtures/, e.g.Work/fixtures/my-variant.json:
{
"title": "My Variant Title",
"preview": "Short preview text for this variant.",
"ads": [],
"images": []
}-
Run
npm run generate:fixtures— the new fixture is picked up automatically (no code changes needed). -
Open
Work/generated/fixtures/my-variant.htmlin a browser to review.
The Node.js CI workflow runs npm run generate:fixtures on every push/PR (Node 20 only) and uploads all generated HTML files — including fixture renders — as a single artifact named generated-html-comparison. To download it:
- Open the workflow run in the GitHub Actions UI.
- Scroll to Artifacts at the bottom of the run summary.
- Click
generated-html-comparisonto download the ZIP. - Unzip and open the
.htmlfiles in a browser to compare renders across fixtures.
The CI job summary also lists every fixture that was rendered in that run.
The project uses Rollup to produce three output formats from Work/src/index.js:
| Format | Output file | Use case |
|---|---|---|
| CommonJS | dist/index.cjs.js |
Node.js require() |
| ES module | dist/index.es.js |
Modern bundlers (webpack, Vite) |
| IIFE | dist/index.iife.js |
Direct <script> tag in browser |
cd Work
npm run build # clean + bundle all formats
npm run bundle # bundle only (no clean step)
npm run clean # remove dist/ and coverage/The package is published to npm as atherdon-old-newsletter-js-outertemplate.
Publishing is automated via the .github/workflows/npm-publish.yml workflow, which triggers on releases. To manually publish:
cd Work
npm publishThe
publishConfiginpackage.jsonsets"access": "public"so the package is published publicly.
Make sure you have installed dependencies inside the Work/ directory specifically:
cd Work && npm installThe root package-lock.json is separate from Work/package-lock.json.
If you cloned the repo in a CI environment or with --no-verify, Husky may not be initialized. Run:
cd Work && npm run prepareThe prepare script is CI-aware and will skip Husky when the CI environment variable is set.
ESLint plugins are resolved relative to the Work/ directory. Always run eslint from Work/, or use the provided npm scripts / shell scripts which set the correct --resolve-plugins-relative-to flag.
Ensure you are running tests from the Work/ directory where jest.config.js and the Babel preset (babel-preset-react-app) are installed:
cd Work && npm test- Create a folder under
Work/src/display/sections/<sectionName>/with four files:<sectionName>.display.js,<sectionName>.mapper.js,<sectionName>.model.js, andindex.js. - Follow the mapper → model → display pattern used by existing sections.
- Export the HTML string from
index.jsand import it inWork/src/methods.js. - Add a corresponding unit test in
Work/tests/unit/<sectionName>.unit.test.js.
- No secrets should ever be committed. The
.gitignoreexcludesnode_modules/but you should add.envfiles to it before storing any API keys or credentials. - The
config.unsubscribefield defaults to'#'— always replace this with a real unsubscribe URL in production emails. Sending emails without a working unsubscribe link may violate CAN-SPAM, GDPR, and similar regulations. - The
contactURL inconfig.jspoints to a Hackernoon-specific sponsorship page — update this if you fork the project for a different newsletter. - CodeQL security scanning runs automatically on every push via
.github/workflows/codeql-analysis.yml. - This project generates HTML strings — always sanitize any user-supplied content before passing it to the template to prevent XSS injection in rendered emails. See the validation & sanitization tools below for suitable libraries.
html-validatehtml-validator-clisanitizercommon-tagshtmljs-parserparse5@tehshrike/html-template-tag
Contributions are welcome! See CONTRIBUTING.md for the full guide.
Quick reference:
- Fork the repository and create a feature branch.
- Add new code in the correct module — see the table in CONTRIBUTING.md § Where to Add New Code.
- Run tests and make sure they pass:
cd Work && npm test - Run the linter and fix any issues:
cd Work && npm run lint:fix - Open a Pull Request against
mainwith a clear description.
Work/is integration and orchestration only — no new core logic.
New template definitions, display sections, and component functions must live in the appropriate package (sub-modules/outerTemplate, packages/template-runtime-display, etc.), not in Work/src/. This rule is documented in ADR 0001 and enforced by the work-policy CI check.
See CONTRIBUTING.md for full details on what belongs where.
These are ideas and known areas for improvement — not commitments.
- Complete the TypeScript / NX migration in
hackernoon/ - Add a live HTML preview mode for local development
- Document each sub-module in
sub-modules/individually - Add Storybook or similar component explorer for email components
- Expand integration test coverage with snapshot tests
- Add a CLI tool for quick template rendering from the command line
- Publish NX workspace packages to npm under a
@llazyemailscope
- markdown-to-email — sibling project that feeds content into this template
- LLazyEmail LinkedIn
- DeepSource Code Quality Dashboard
- 5 Reasons Why Newsletters Should Be Part of Your Business Strategy
- Organizing an Advanced Structure for HTML Email Template
- How I Started to Build React Components for Email Templates
- Introducing a Simple npm Module with Email Templates
- Glossary for Non-Techies
- Email Marketing and How to Curate an Effective Business Newsletter
- Exploring Substack for Building Your Newsletter
- Building a Design System for Email Templates (React)
- Together4Victory: List of Email Marketing Tools
- Cool Newsletters for Developers — Part 1
- Cool Resources for Sending Emails
- Template Modularization: Refactored the codebase to modularize the template system. Introduced
outerTemplate,display runtime,HN preset definitions, andtemplate-engineworkspace packages to enhance code maintainability. - Template Definition Updates: Moved
hn-without-adsand HN template definitions, along with their assembly logic, to the newouterTemplateruntime, improving isolation between data, definitions, and generation. - Testing & Artifacts: Added integration tests using real data, generating and committing verified HTML outputs for visual and functional validation.
- Validation & CLI: Introduced robust template input schema validation and a new generator CLI for automation and error reduction.
- File & Package Structure: Created new folders and packages for
outerTemplate,display runtime, HN definitions, and utility modules. - Documentation: Updated README and documentation with new architectural details and a roadmap for further development.
- NPM Packaging: Updated
.npmignoreto properly exclude generated artifacts and ensure clean npm releases.
The outerTemplate module is responsible for encapsulating the structure, definitions, and static assembly logic for email templates (such as "HN" and "hn-without-ads"). This module organizes all static aspects of template construction, providing a clear separation from dynamic, data-driven, or rendering-specific logic. While outerTemplate holds most or all static components, actual HTML rendering and runtime processing may also involve related modules like display runtime and template-engine. This modular approach enhances maintainability and makes each concern explicit within the repository's structure.
Architecture Decision Records (ADRs) capture significant design choices made during the evolution of this project. They are stored in docs/adr/.
| ADR | Title | Status |
|---|---|---|
| 0001 | Module Boundaries and Dependency Direction | Accepted |
The repository is structured around four primary areas of responsibility:
| Area | Owns |
|---|---|
sub-modules/outerTemplate |
Outer-shell layout components, template registry, and template definitions |
packages/template-runtime-display |
The three-step display pipeline (mapper → model → renderer) and all section implementations |
packages/template-engine |
Generic template factory utilities, shared types, and input validation helpers |
Work/ |
Integration tests, CLI scripts, build configuration, and sample fixture data — no core logic |
Dependency direction: Work/ may depend on any package or sub-module. Packages and sub-modules must never import from Work/. Lower-level packages (template-engine) must not import from higher-level ones (template-runtime-display, outerTemplate).
See docs/adr/0001-module-boundaries.md for the full rules, migration guidance, and acceptance criteria.
The original README is preserved at README.old.md for historical reference. It contains the original project notes, architecture sketches, and early development narrative written during the initial build phase. No content has been changed.
This project is licensed under the MIT License. Refer to the "license": "MIT" field in Work/package.json.
- Arthur Tkachenko — creator and primary author
- The Hackernoon engineering team for the original email pipeline
- RollupJS, Jest, NX communities
- All contributors who submitted issues, PRs, or articles about the project