templa-js
v0.13.2
Published
A tiny HTML template loader using <template src>. Read as tempura.
Maintainers
Readme
templa
🍤 A tiny HTML template loader. Pronounced "tempura".
Wraps your data in
<template src>like batter wraps ingredients.
templa is a tiny dependency-free script that lets you split HTML into reusable partials, pass parameters, and use Handlebars-like syntax — all powered by the native <template> element.
It works in two modes:
- Runtime (
templa.js) — partials are fetched and inlined in the browser - Build (
npx templa-js build) — partials are inlined ahead of time into static HTML
<!-- index.html -->
<body>
<template src="_partials/header.html" title="Home" logged-in="yes"></template>
<main>...</main>
<template src="_partials/footer.html"></template>
<script src="templa.js"></script>
<script type="module">await templa.start();</script>
</body><!-- _partials/header.html -->
<header>
<h1>{{title}}</h1>
<template if="logged-in">
<a href="/logout">Logout</a>
</template>
<template unless="logged-in">
<a href="/login">Login</a>
</template>
</header>Why templa?
- No build step — drop in a
<script>tag - No dependencies — pure vanilla JavaScript
- Standard HTML — uses the native
<template>element, not custom tags - Tiny — ~240 lines of source, ~3.5KB gzipped
- HTML-native —
{{var}}for values;<template if>/<template unless>for conditionals - Active-nav aware —
<nav>links to the current page getaria-current="page"automatically - Recursive — partials inside partials just work
- Resource-aware — waits for
<link rel="stylesheet">and<script src>inside partials before resolving
Install
Via <script> tag (CDN)
<!-- minified (auto-generated by jsDelivr) -->
<script src="https://cdn.jsdelivr.net/npm/templa-js/templa.min.js"></script>
<!-- or unminified, for debugging -->
<script src="https://cdn.jsdelivr.net/npm/templa-js/templa.js"></script>jsDelivr serves
templa.min.jsby minifying the source on the fly, so there is no separate minified file to maintain. Pin a version with[email protected]if you want immutable URLs.
Via npm
npm install templa-jsInit
Bootstrap a new project in the current directory:
npx templa-js init # minimal src/ tree
npx templa-js init --ai # also write the AI agent guides
npx templa-js init --force # overwrite existing filesThe result is a buildable project: run npx templa-js build immediately afterwards and dist/ will be produced. The --ai flag adds four project-root markdown files that brief AI coding agents on templa's conventions: AGENTS.md (orchestrator guide), PLANNER.md (skeleton planning prompt), SECTION.md (compact sub-agent brief), and CLAUDE.md (pointer so Claude Code finds the others).
Usage
Basic
Mark any place you want a partial with a <template> element:
<template src="_partials/nav.html"></template>Then call templa.start() to expand all of them:
<script src="templa.js"></script>
<script type="module">
await templa.start();
// any post-init: Alpine.initTree(document.body), AOS.init(), Swiper, …
</script>templa.start() returns a Promise that resolves once head + body partials have been expanded. The module-script form is required for top-level await. Anything written after the await runs once the DOM has been fully assembled — perfect for plugins like Alpine.js, AOS, Swiper, etc.
Passing data
Each attribute on the calling <template> becomes a string-valued data key inside the partial.
<template src="_partials/card.html" title="Hello" body="Welcome."></template>Conditionals (<template if="key">) are existence-based, so any non-empty string is truthy — featured="yes" is enough to enable a block.
Reserved attributes — these are not collected as data: src (the partial path), slot (slot filler name), if / unless (conditional markers). Any data-* attribute is also skipped, by HTML metadata convention.
Keys are case-insensitive. HTML attribute names are case-insensitive in the spec and the browser DOM lowercases them, so templa normalises both the attribute name and the {{var}} lookup to lowercase. <template ctaLabel="X"> and {{ctaLabel}} both resolve via ctalabel and behave identically in runtime and build mode. Use kebab-case (cta-label, og-image, hero-bg-color) — it survives every layer unchanged and reads as idiomatic HTML.
Data is per-include — it does not cascade. Each <template src> sees only the attributes written on that tag; a parent include does not pass its data down into partials nested inside it. A value set on the page therefore does not automatically reach a partial two levels deep (e.g. a footer pulled in by a layout). Three patterns:
- Hardcode global constants (site name, © year, social links) directly in the shared partial — they rarely vary per page.
- Thread explicitly when a page value must reach a nested partial: write the key onto the nested include inside the intermediate partial —
<template src="footer.html" credits="{{credits}}">in the layout, withcreditspassed to the layout. The layout renders{{credits}}before the footer is expanded, so the value flows through. - Lift the consumer to the page level instead of nesting it.
Unresolved {{key}} is reported by templa check, so a forgotten value fails the build instead of shipping silently.
Template syntax
| Syntax | Effect |
|---|---|
| {{key}} | HTML-escaped variable |
| {{{key}}} | Raw variable (no escape) — use only for trusted HTML |
| <template if="key">…</template> | Block kept when data[key] is truthy |
| <template unless="key">…</template> | Block kept when data[key] is falsy |
Conditionals can be nested. Variables fall through unchanged when the key is missing from data.
Layouts and slots
A partial can declare insertion points with <slot>. Pages fill those slots by writing content inside the calling <template src>.
Keep layouts as body fragments (no <!DOCTYPE> / <html> / <head> / <body> wrapper) so they work in both runtime and build modes. Each page provides its own document skeleton and embeds the layout where the body content goes.
<!-- _layouts/main.html (body fragment) -->
<header><slot name="nav">Default Nav</slot></header>
<main><slot></slot></main>
<footer><slot name="footer">© 2026</slot></footer><!-- page.html -->
<!DOCTYPE html>
<html>
<head>
<title>Home</title>
</head>
<body>
<template src="_layouts/main.html">
<template slot="nav">
<a href="/">Home</a>
<a href="/about">About</a>
</template>
<h1>Welcome</h1>
<p>Anything outside <template slot> goes into the default slot.</p>
<!-- footer slot is omitted, so its fallback renders -->
</template>
<script src="https://cdn.jsdelivr.net/npm/templa-js/templa.min.js"></script>
<script type="module">await templa.start();</script>
</body>
</html>Rules:
<slot>(no name) receives every node from the calling<template>that is not wrapped in<template slot="...">.<slot name="X">receives the content of the matching<template slot="X">filler.- A slot's own children are the fallback — they render when no filler is supplied.
- Slot fillers may themselves contain
<template src="...">partials; they are expanded recursively in the call site's directory context.
This pattern works identically with the build CLI — npx templa-js build inlines the layout into the output and you can drop the script tags.
Active navigation links
Any <a> inside <nav> whose href resolves to the current page is automatically marked with aria-current="page" — at build time (against the output path) and at runtime (against location). Style the active link with one CSS rule; no per-page selectors, no passing a page variable around:
<!-- _partials/common-header.html -->
<style data-merge="css/style.css">
nav a[aria-current="page"] { font-weight: bold; }
</style>
<header>
<nav>
<a href="/">Home</a>
<a href="/about.html">About</a>
</nav>
</header>Matching rules:
| Case | Behaviour |
|---|---|
| / vs index.html | equivalent — a trailing /index.html normalizes to / |
| Pretty URLs | /about ≡ /about.html ≡ /about/, so .html hrefs still match on hosts that serve extensionless paths (Netlify, Vercel) and vice versa |
| Query strings | ignored; comparison is by path only |
| In-page anchors (#x, /#x, /page#x) | skipped — any href with a #fragment is a jump within a page, not whole-page navigation, so it is never marked (keeps #x and /#x consistent) |
| External / cross-origin links | never match |
| Hand-written aria-current | respected — templa won't touch that link |
Links outside <nav> are never marked: per the ARIA definition, aria-current="page" identifies the current page within a set of navigation links. As a side effect, screen readers announce the active link correctly for free.
Nested partials
Partials can include other partials. templa keeps expanding until no <template src> remains.
Loading order
templa.start() kicks off head expansion synchronously so its fetches overlap with the rest of HTML parsing — critical when partials in <head> include <link rel="stylesheet">. Body expansion waits for DOMContentLoaded. The returned Promise resolves once both phases complete.
Relative paths in partials
URLs authored inside a partial are relative to that partial's own location, not to the page that includes it — so a partial can live in _partials/ and be shared by pages at any directory depth, resolving the same from every page.
This applies to <template src> and to asset URLs: src (on any element), href on <link>/<use>, srcset, poster, and CSS url() inside <style> blocks and style="" attributes (so co-located <style data-merge> keeps working). templa rewrites them to root-absolute paths during expansion (../css/style.css in _partials/head.html → /css/style.css), identically at build time and runtime.
<!-- _partials/common-head.html -->
<link rel="stylesheet" href="../css/style.css" /> <!-- → /css/style.css -->
<style data-merge="css/style.css">
.hero { background: url(../img/hero.png); } <!-- → url(/img/hero.png) -->
</style>Left untouched (write these as you mean them):
| Not rebased | Why / what to write |
| --- | --- |
| <a href="…"> | Link targets are pages, not co-located assets. Use root-absolute (/about.html) so a shared nav works from every page. |
| #fragment, url(#id), mailto:/tel:/https:/data:, //host, /already-absolute | Already unambiguous. |
| {{templated}} URLs | Filled by render afterwards — you own the value (pass it root-absolute). |
| data-* attributes | Reserved as metadata. |
Note: root-absolute output assumes the site is served from the domain root. At runtime any deploy sub-path is carried automatically via
location; the static build assumes root (a configurable base path is not yet supported).
Co-located styles
A partial can carry its own CSS in a <style> block tagged with data-merge="<file>". The build CLI extracts it once per partial and appends it to the named output stylesheet — even if the partial is used 100 times, its rules are written exactly once.
<!-- _partials/card.html -->
<style data-merge="style.css">
.card { background: #fff; border: 1px solid #ddd; padding: 1rem; }
</style>
<article class="card">
<h3>{{title}}</h3>
</article>At runtime, the same dedupe applies: the <style> block stays in the DOM for the first expansion of a given partial and is stripped on subsequent ones. A <style> without data-merge is treated as plain inline CSS (existing behaviour).
Caching and recursion
- Identical
<template src>URLs are fetched once per page (in-memory cache). - A safety guard stops expansion after 50 passes to prevent infinite loops from circular includes.
API
templa.start()
Loads all <template src> elements (head first, then body). Returns a Promise that resolves once everything is mounted. Use with await from a <script type="module">.
templa.run(selector?)
Lower-level: runs a single expansion pass against selector (default: 'template[src]'). Returns a Promise. Useful if you load partials dynamically.
await templa.run('#my-region template[src]');Content Security Policy
The runtime never calls eval or new Function. There is no 'unsafe-eval' requirement; a plain script-src 'self' works.
For pages that don't need a runtime at all, build with npx templa-js build — the output is plain HTML with no template syntax left.
Caveats
{{key}}HTML-escapes its value. If you need to inject HTML, use{{{key}}}and make sure the value is trusted.- Original
<template src>elements are removed from the DOM after expansion. if/unlessare resolved against the data passed by the calling<template src>, so conditionals only make sense inside a partial. A bare<template if="…">written at the page top level has no calling data — it is left untouched (and a<template>renders its content inertly, i.e. invisibly). Both the build CLI and the runtime warn when they find one.- Only the exact attribute names
if/unless/src/slotare special. Framework directives that merely end in one of those (Alpine's<template x-if>, Vue's<template v-if>, anydata-*) are left completely alone, so templa partials can host them. - This project is in early development (0.0.x). API may change.
Build (CLI)
For static deployment, expand all <template src> ahead of time:
npx templa-js build -i ./src -o ./distThe CLI reads every .html file under the source directory, recursively inlines its partials with the same syntax as the runtime, and writes the result to the output directory.
File convention
Files and directories whose names start with _ are treated as partials and are not copied to the output:
src/
├── index.html ← entry, written to dist/
├── about.html ← entry, written to dist/
└── _partials/ ← skipped (partials only)
├── header.html
└── footer.htmlReference partials with a relative path:
<template src="_partials/header.html" title="Home"></template>Options
| Flag | Default | Description |
|---|---|---|
| -i <dir> | ./src | Source directory |
| -o <dir> | ./dist | Output directory (cleared before each build) |
| --strict | off | Exit 1 on any problem: missing partial, unresolved {{key}} in output, page-level <template if/unless>, unclosed <template> |
| --no-format | off | Skip the automatic prettier pass on the output |
| --version | | Print version |
| --help | | Print usage |
Check (dry run)
npx templa-js check -i ./srcRuns the full build pipeline without writing anything and always behaves as --strict: exit 0 means the source is sound, exit 1 means problems (each one listed). Designed as a machine-readable gate for CI and AI agents — verify a skeleton before filling sections in parallel, or verify a single section after writing it.
Build vs runtime
Both modes share the same template syntax, so a partial works in either:
| | Runtime (templa.js) | Build (npx templa-js) |
|---|---|---|
| When partials are inlined | At page load, in the browser | Once, ahead of time |
| Dependencies | none | none |
| Output | dynamic DOM | static HTML files |
| Use case | quick prototypes, dev | production deploy, SEO, static hosting |
You can also use both: ship the static HTML for first paint and keep templa.js for any partials you want to load dynamically later.
Recommended companion: Alpine.js
templa handles composition (partials, layouts, variables, conditionals at build or load time). For interactive behaviour (modals, dropdowns, counters, form state), pair it with Alpine.js — also HTML-first, no build step, ~15 KB.
<!-- somewhere in your <head> -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
<!-- in any partial -->
<section x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Hidden until clicked.</div>
</section>Decision rule: templa for everything that can be resolved before the user clicks; Alpine for everything that depends on user interaction.
Working with AI agents
If you use Claude Code, Cursor, Aider, Copilot, or similar AI tools, npx templa-js init --ai drops four files that do most of the work:
AGENTS.md— file conventions, two-phase workflow, syntax, common pitfalls. The orchestrating agent's source of truth.PLANNER.md— an instruction prompt that turns a free-form site brief into a concreteplan.md(page set, wireframes, section list, design tokens, file inventory) before any file is written.SECTION.md— a compact brief for parallel section sub-agents. Hand each sub-agent this file instead of AGENTS.md: the same rules they need, at a fraction of the tokens per agent.CLAUDE.md— a generated pointer file, because Claude Code auto-loads CLAUDE.md (not AGENTS.md); Cursor/Codex-style tools read AGENTS.md directly.
Philosophy
templa is not trying to replace HTML. It exists because HTML does not yet have a native way to include partials, compose layouts, and pass small pieces of data between templates.
The biggest competitor is native HTML. That is also the goal. If one day HTML supports this natively, templa has done its job. Until then, templa is a tiny bridge.
Concrete consequences of that stance:
- Attribute names follow the platform:
<template src>mirrors<img src>/<script src>/<iframe src>. We deliberately don't usedata-src. The baresrclets editors and IDEs treat it like a real file reference (path completion, jump-to-file, refactor-rename). - Conditionals are written as
<template if="key">…</template>and<template unless="key">…</template>— no{{#if}}Mustache block, no expressions, no helpers. They are existence-based only. - Active nav links get
aria-current="page"automatically. CSS Selectors Level 4 specced:local-linkfor exactly this and no browser shipped it; templa bridges the gap using the ARIA attribute HTML authors already write by hand. - Co-located styles use
data-merge="style.css"— adata-*attribute, because that is HTML's documented hook for component-private metadata. - Anything beyond block-level conditionals (conditional attributes, dynamic class lists, loops) is out of scope for the core and lives in plugins.
The core stays small enough to read in one sitting. Everything else is a plugin.
