templa-js
v0.10.1
Published
A tiny HTML template loader using <template src>. Read as tempura.
Downloads
253
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 - 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 AGENTS.md and PLANNER.md
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 two project-root markdown files that brief AI coding agents on templa's conventions and a planning prompt for site skeletons.
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.
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.
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 nested partials
<template src> inside a partial is resolved relative to that partial's URL, not the page. So _partials/layout.html can reference <template src="./header.html"> and it will resolve to _partials/header.html correctly.
Other resources (<img src>, <link href>, <script src>) still resolve against the page URL — use absolute or root-relative paths for those.
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. - 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) |
| --version | | Print version |
| --help | | Print usage |
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, two files do most of the work:
AGENTS.md— file conventions, two-phase workflow, syntax, common pitfalls. Drop it into a templa project root and the agent reads it as the 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.
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. - 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.
