templa-js
v0.25.0
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 -->
<head>
<template src="partials/head.html" title="Home"></template>
<script src="templa.js"></script>
</head>
<body>
<template src="partials/header.html" title="Home" logged-in></template>
<main>...</main>
<template src="partials/footer.html"></template>
</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; loading it is the bootstrap - No dependencies — pure vanilla JavaScript
- Standard HTML — uses the native
<template>element, not custom tags - Tiny — ~385 lines of source, ~4.6KB minified + 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 --force # overwrite existing filesThe result is a buildable project: run npx templa-js build immediately afterwards and dist/ will be produced.
Usage
Basic
Mark any place you want a partial with a <template> element:
<template src="partials/nav.html"></template>Then load the script. That is the whole bootstrap — there is no start call:
<head>
<template src="partials/head.html"></template>
<script src="templa.js"></script>
</head>Put the tag in <head>, after the partials it should expand, and give it no defer or async. All four pages that order produces render identically, so this is a speed rule, not a correctness one — see Loading order for what each part buys.
Waiting for the page
Code that reads the assembled DOM has to wait for it. templa.ready is a Promise that resolves once head and body partials are in place:
<script type="module">
await templa.ready;
AOS.init(); new Swiper('.swiper'); ScrollTrigger.refresh();
</script>templa:ready is the same signal as a DOM event, for code that runs before templa.js itself:
<script>
document.addEventListener('templa:ready', () => AOS.init());
</script>Prefer the Promise. Awaiting it is safe at any time — a settled Promise resolves immediately, so a script that loads long after assembly still gets its turn, where a one-shot event would already be gone. It is also the form that survives the build (see Waiting, after the build).
DOMContentLoadedis too early. templa's own body pass starts there and then awaits network, so a handler you add sees a page whose partials have not landed yet. It does not throw —querySelectorjust returnsnull. Wait ontempla.readyinstead.
Alpine.js needs neither hook: it installs a MutationObserver at startup and initialises partials as templa inserts them. Libraries that scan the DOM once — AOS, Swiper, GSAP ScrollTrigger, Lenis — are the ones to put behind the wait.
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">) test whether the attribute is present, not what it holds — the same rule HTML uses for disabled and checked. So the bare form reads naturally, and "false" is spelled by leaving the attribute out:
<template src="partials/header.html" title="Home" logged-in></template>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.Threading carries values, not flags.
<template src="footer.html" premium="{{premium}}">writes thepremiumattribute into the layout's source unconditionally, and conditionals test presence — so<template if="premium">inside the footer is permanently true and itsunlessbranch is unreachable, whatever the page passed. There is no way to conditionally emit an attribute, so a flag can only be read one level deep. Put the consumer where the flag is instead.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 the key attribute is present |
| <template unless="key">…</template> | Block kept when the key attribute is absent |
Conditionals can be nested. Variables fall through unchanged when the key is missing from data.
Always write a closing </template> and </slot>. HTML has no self-closing
syntax for non-void elements, so a browser ignores the / in
<template src="x" />, keeps the element open, and swallows the rest of the page
as its content — which then disappears when the include is replaced. The build
cannot reproduce that, so the same source would render two different pages.
templa check refuses it, along with everything else in the table below.
Conditionals test presence, not value
if / unless follow HTML's boolean-attribute rule: an attribute that is there
is true, whatever it holds, and false is written by omitting it.
| Written on the calling <template> | <template if="logged-in"> |
|---|---|
| logged-in | kept |
| logged-in="" | kept |
| logged-in="yes" | kept |
| logged-in="no" | kept — the value is not read |
| (attribute omitted) | dropped |
The fourth row is the one to internalise: there is no falsy value. ="no",
="false" and ="0" all enable the block, because the value is never
consulted. Remove the attribute to turn a block off, or invert with
<template unless="key">.
This is forced by the platform, not chosen for convenience: the DOM reports a
valueless attribute as "", identically to logged-in="", so the runtime
cannot distinguish the two. Treating presence as the signal is the only reading
under which build and runtime can agree — and it is what makes the bare
logged-in form work at all.
The value still flows through as data: {{logged-in}} interpolates it as
normal (the empty string, for the bare form). Only if / unless ignore it.
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>
<script src="https://cdn.jsdelivr.net/npm/templa-js/templa.min.js"></script>
</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>
</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 strips the script tag for you.
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 in your stylesheet; no per-page selectors, no passing a page variable around:
/* css/style.css */
nav a[aria-current="page"] { font-weight: bold; }<!-- partials/common-header.html -->
<header>
<nav>
<a href="../index.html">Home</a>
<a href="../about.html">About</a>
</nav>
</header>An href in a partial is written relative to the partial and rebased per page — see Relative paths in partials. The match runs afterwards, on the rebased value, so it is the href the reader would follow that is compared.
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
Loading templa.js in a document starts the expansion. There is no call to make: <template src> says what the page wants, the way <img src> does, and the script that can honour it does so on arrival.
Head expansion is kicked off synchronously, so its fetches overlap the rest of HTML parsing — which matters because a <head> partial usually carries the stylesheet. Body expansion waits for DOMContentLoaded. templa.ready resolves once both phases complete.
That synchronous pass can only see the <head> the parser has already produced, so markup order decides whether it finds anything:
<head>
<template src="partials/head.html"></template> <!-- parsed first… -->
<script src="templa.js"></script> <!-- …so this pass sees it -->
</head>Reversed, the pass runs against an empty <head> and the partial waits for the DOMContentLoaded re-run instead. Measured on a 580 KB page, the order above starts the partial's fetch 5–6 ms before DOMContentLoaded; the reverse order, and the older bottom-of-<body> placement, both start it at DOMContentLoaded — no overlap at all. The gap is however much parsing is left after the tag, and it carries the stylesheet with it.
No defer or async on the templa tag. Either one postpones execution until parsing is finished, which is precisely the window this is trying to use. templa warns in the console if it finds one.
Every ordering renders the same page. Nothing here can fail a build or throw an error — it is entirely a question of when the fetches start.
Restarting after a client-side navigation
templa.start() re-assembles the page from scratch and repoints templa.ready at the new pass. Use it when a router has swapped the document's contents:
await templa.start();It is also the entry point when there is no <script> element to start from — a bundler import. Concurrent calls are serialized, so an explicit start() on an auto-started page cannot expand the same include twice.
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 <a>/<link>/<use>, srcset, poster, CSS url() inside <style> blocks and style="" attributes — see Styles in partials below for what that means for a <style> block — and the content of the four <meta> keys the OGP / Twitter Card specs type as a URL (see OGP images in a shared head below). templa rewrites them relative to the including page during expansion, identically at build time and runtime: ../css/style.css in partials/head.html comes out css/style.css on index.html and ../css/style.css on blog/post.html — the same file named from two depths.
<!-- partials/common-head.html, included by index.html -->
<link rel="stylesheet" href="../css/style.css" /> <!-- → css/style.css -->
<meta property="og:image" content="../img/ogp.png" /> <!-- → img/ogp.png -->
<style>
.hero { background: url(../img/hero.png); } <!-- → url(img/hero.png) -->
</style>Page-relative output carries no assumption about where the site is mounted, so the build works at any deploy sub-path with nothing configured — a project site at https://user.github.io/repo/ just works, as does the same dist/ served from a domain root.
<a href> is rebased like everything else, so one rule covers a partial entirely: write every path relative to the partial — nav links included. A shared nav is ../about.html from partials/, not a special case.
Left untouched (write these as you mean them):
| Not rebased | Why / what to write |
| --- | --- |
| #fragment, url(#id), mailto:/tel:/https:/data:, //host, /already-absolute | Already unambiguous. Write /about.html for a link you want left exactly as typed. |
| {{templated}} URLs | Filled by render afterwards — you own the value, and root-absolute is the only spelling that reads the same from every page. |
| data-* attributes | Reserved as metadata. |
| <meta content> outside the four URL-typed keys | content is free text on every other key — a description may legitimately read like a path. Only the keys listed below are rebased. |
Pass --base-path <path> to prefix rebased URLs instead of relativising them (--base-path /myrepo → /myrepo/css/style.css). It is only needed when a page's URL depth differs from its path in the output — SPA rewrites, trailing-slash serving. --base-path / restores the root-absolute output of 0.20.x, and is the only mode in which a built page may carry a <base href> (see What check refuses).
OGP images in a shared head
A shared <head> partial can name its OGP image the same way it names its
favicon — relative to the partial — because content is rebased on the four
<meta> keys the OGP and Twitter Card specs define as a URL:
| Key | Rebased |
| --- | --- |
| og:image | ✅ |
| og:image:url | ✅ |
| og:image:secure_url | ✅ |
| twitter:image | ✅ |
| anything else (description, og:title, og:site_name, …) | ❌ verbatim |
The key decides, not which attribute carries it: property="og:image" and
name="og:image" are both rebased, as are both spellings of twitter:image.
<!-- partials/common-head.html -->
<meta property="og:image" content="../img/ogp.png" />
<meta name="twitter:image" content="../img/ogp.png" /><!-- index.html → content="img/ogp.png" -->
<!-- blog/post.html → content="../img/ogp.png" -->The skip rules are the ones href already follows: an absolute URL, a //host,
a /-rooted path, a {{templated}} value, and an empty content all pass
through untouched. So a partial that points at a CDN keeps pointing at it.
Scrapers generally want an absolute
og:image. A rebased relative URL is correct HTML and resolves in a browser, but if a crawler you care about insists on an absolute one, write it absolute — templa leaves it alone.
Styles in partials
A partial may contain a plain <style> block. By default templa does not extract it, dedupe it, or treat it specially — it is ordinary HTML that renders wherever it sits, once per inclusion, on both targets (only its CSS url()s are rebased, like any other <style> block). Pass --merge-styles at build time to collect those blocks into one stylesheet instead — see Merging partial styles below.
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.ready
A Promise that resolves once head and body partials are mounted. Always present, safe to await at any time, and repointed by each start() at the pass it kicked off. The templa:ready event on document carries the same signal for listeners registered before templa.js runs.
templa.start()
Loads all <template src> elements (head first, then body) and returns a Promise that resolves once everything is mounted. Not needed on an ordinary page — the script tag already did this. Call it to re-assemble after a client-side navigation, or to bootstrap when templa was imported through a bundler rather than loaded as a tag. Calls are serialized, never interleaved.
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, so there is no 'unsafe-eval' requirement.
Nor is there an 'unsafe-inline' one: the bootstrap is the <script src> tag itself, and a page that needs no post-init code carries no inline script at all. Where post-init code is needed, put it in an external file and wait there — templa.ready is reachable from any script, and unlike the event it cannot be missed by loading late:
// js/app.js
await templa.ready;
AOS.init();A plain script-src 'self' covers that page completely. (Before 0.22.0 the mandatory <script type="module">await templa.start();</script> made this claim false in practice — an inline script needs 'unsafe-inline', a nonce, or a hash.)
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
DOMContentLoadedfires before the partials land. templa's body pass starts on that event and then awaits network, so a handler added alongside it sees an unexpanded page — silently, sincequerySelectorreturnsnullrather than throwing. Wait ontempla.ready.defer/asyncon the templa tag cost the head-expansion overlap. The page still renders; the console says so.- Loading
templa.jsin a document expands that document. There is no inert mode. If you want the transforms without the DOM pass — embedding the build in a browser, say — import the module instead of writing a<script>tag, since auto-start keys ondocument.currentScript. {{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.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.
What reaches the output
A file's name decides nothing. What reaches dist/ is decided by what the
build did with the file:
src/
├── index.html ← included by nobody → written as a page
├── about.html ← included by nobody → written as a page
├── partials/
│ ├── header.html ← expanded into both pages → not written
│ ├── footer.html ← expanded into both pages → not written
│ └── img/logo.png ← an asset → copied
└── css/style.css ← an asset → copiedAn .html that a page pulled in with <template src> is consumed: its
markup is already inside the pages that included it, so the file is not written
again — and it is not checked as a page either, or the {{key}} it exists to
receive would fail every build.
Being consumed means a file someone wrote a <template src> against is being
used as a fragment. <template src> splices text, so a partial must not
carry <!DOCTYPE> / <html> / <head> / <body> (see Layouts and
slots) — and check reports a dropped file that breaks
that rule, since it is a page about to vanish from the output, not a fragment.
The .html extension buys editor highlighting, attribute completion and
jump-to-file on <template src>; it is not a claim that the file is a page.
Everything else is copied through, including the dotfiles a static host needs
(.nojekyll, .htaccess, .well-known/). Only .git, .hg, .svn,
.DS_Store, node_modules, and a nested output directory (see skipDir
under Build in the browser) are skipped.
A directory is not an output unit — it appears in dist/ because something was
written inside it. A directory holding nothing but consumed templates is never
created.
Two consequences worth knowing:
- An
.htmlnobody includes is a page. A half-written fragment left in the tree is written out andcheckreports its unresolved{{key}}, which reads correctly as "this file is dead". - Linking to a partial is reported.
<a href="partials/card.html">beside<template src="partials/card.html">points at a file that is never written.checknames it; link to a real page instead.
Reference a partial 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) |
| --base-path <path> | off | URL path the site is served from, e.g. /myrepo. Only needed when a page's URL depth differs from its path in the output; by default URLs are page-relative and work at any sub-path |
| --merge-styles <dest\|glob:dest> | off | Collect <style> blocks that came from partials into <dest>. Repeatable — see Merging partial styles |
| --strict | off | Exit 1 on any problem — see below |
| --format | off | Format the output with prettier — the project's copy, a global install, or one npx fetches on the spot |
| --version | | Print version |
| --help | | Print usage |
An unrecognised flag is an error, not a no-op — a mistyped --stict fails
loudly rather than quietly disabling the gate. Because -o is cleared before
each build, it is refused when it names the source directory or any directory
containing it.
Everything that is not a page or a partial is copied through verbatim — see What reaches the output.
Merging partial styles
A partial may carry its own <style>. That is ordinary HTML — templa leaves it
alone by default, so the runtime and the build produce the same page.
Pass --merge-styles and the build collects those blocks into one stylesheet
instead:
npx templa-js build --merge-styles css/style.cssEvery <style> that came from a partial moves into css/style.css and
disappears from the pages. A <style> written directly in a page is never
touched — that is usually deliberate critical CSS.
Route them when one stylesheet is not enough. The flag repeats, the first matching glob wins, and the order you write the flags is the cascade order:
npx templa-js build \
--merge-styles "partials/reset.html:css/style.css" \
--merge-styles "partials/common-*:css/style.css" \
--merge-styles "partials/*:css/sections.css"* matches within one path segment, ** crosses them. Within a glob, sources
are ordered by path, so the result does not depend on the machine, the page
count, or the order the filesystem lists directories.
This replaces <style data-merge="…">, removed in 0.19.0. The configuration
moved from the markup to the build because merging is a transformation of the
output — the same category as the prettier pass — not a change to what the
markup means. With no flag, dist and the runtime agree.
Waiting, after the build
Built output is already assembled, so the build removes the runtime: the
<script src="…templa.js"> tag (opt out with data-keep) and the wait itself.
A wait written as its own statement on its own line is lifted out and the
code around it is left in place:
<!-- src/index.html --> <!-- dist/index.html -->
<script type="module"> <script type="module">
await templa.ready; AOS.init();
AOS.init(); </script>
</script>One source, both targets: the runtime honours the wait, the build deletes it,
and AOS.init() runs either way.
Two shapes cannot be lifted out, and check reports them rather than shipping a
page whose init silently never happens:
| Shape | What happens in dist/ | Fix |
|---|---|---|
| templa.ready.then(…), const p = templa.start() | templa is not defined | Rewrite as a bare await templa.ready; line, or data-keep |
| addEventListener('templa:ready', …) | No error — nothing ever fires the event, so the callback never runs | Same |
The event is a callback body, not a line, so there is nothing to strip and
nothing to keep it honest but the report. data-keep is the escape hatch when
you want the runtime in the output anyway — for partials loaded dynamically
after first paint, say.
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: verify the source is sound before committing, deploying, or generating further files against it.
What check refuses
templa's promise is that one source renders one page, whether it is assembled in the browser or by the build. Some markup makes that impossible: the browser has an HTML parser and the build has regexes, so for these there are two defensible readings and no way to pick one without being wrong on the other target. They are reported rather than guessed at.
| Refused | Why the two targets disagree | Fix |
|---|---|---|
| <template … />, <slot … /> | The browser keeps the element open and swallows what follows; the build treats it as a leaf | Write the closing tag |
| Unclosed <template> | The block is dropped by the build; the browser keeps consuming | Add </template> |
| Unquoted attribute value on <template> | The build reads only quoted values, so the data goes missing | Quote it: title="Hi" |
| Attribute name outside [A-Za-z0-9_:.-] on <template> (@click, #ref) | The whole tag is unparseable to the build, so it skips an include the browser expands | Move the directive to a child element |
| A named entity outside templa's table | The browser decodes every HTML5 name; the build carries Latin-1 plus common punctuation | Use the numeric reference (♣) |
| <base href> | It retargets the page-relative URLs the build wrote, sending them where the files are not; the runtime resolves from document.baseURI, which is the <base>, so it still finds them | Remove it, or pass --base-path — root-absolute URLs are out of <base>'s reach, so it is accepted there |
| A script still naming templa after stripping | The build removed the runtime, so the reference is live in the browser and undefined in dist/ | See Waiting, after the build |
| Missing partial, unresolved {{key}}, page-level <template if/unless> | — | — |
| <a href> resolving to a consumed template | Not a two-target disagreement like the rows above — the file was consumed as a <template src> target, already inlined into the pages that included it, and is never written, so the link resolves to nothing in dist/ | Link to a real page instead |
| A dropped template carrying a <!DOCTYPE>/<html> wrapper | Not a two-target disagreement either — a file used as a template must be a body fragment, and this one is not: it opens with a document wrapper, so it is not written, and it is not the page its author thinks it is | Stop including it with <template src>, or strip the <!DOCTYPE>/<html>/<head>/<body> wrapper so it is a real partial |
Entities in ordinary use — © — é “ ½ and
the rest of Latin-1 — are supported and decode identically on both targets.
Names are case-sensitive, as in HTML: É is É and é is é.
{{key}} is not interpolated inside <script>: it escapes for HTML, and HTML
escaping inside JavaScript is always wrong. <pre> and <textarea> do
interpolate — their bodies are HTML text, where escaping is correct.
The unresolved-{{key}} scan looks only at rendered markup: braces inside
<script>, <pre>, <textarea> and HTML comments are content, so documenting
the syntax does not fail the gate. One ambiguity remains by construction — a
client-side framework using the same mustache syntax in ordinary markup (Vue,
Angular) is indistinguishable from a templa typo. Bind those values with the
framework's own directive (v-text, x-text) instead of interpolation.
The <base href> scan skips <script>, <textarea> and comments, where a
<base> is inert — but not <pre>. <pre> suppresses interpolation, not
element parsing, so a <base href> written in a code sample there really does
move the page's base.
The consumed-template link scan has the same exclusion as <base href>: it
skips <script>, <textarea> and comments, not <pre> — an unescaped
<a href> there is content, not a link, but a live one in a code sample
really is a link.
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 — with data-keep, so the build leaves the tag alone — for any partials you want to load dynamically later, as long as that partial is not also reached by a <template src> somewhere in the built pages — the build would consume it and it would not be in dist/ for the runtime to fetch.
Build in the browser
The same build runs without Node. build.js has no require, no process,
no __dirname — nothing in it touches the filesystem, the network, or the DOM
directly. Give it two stores — a source and a destination — and it produces
the identical dist/:
<script src="templa.js"></script>
<script src="build.js"></script>
<script type="module">
const root = await navigator.storage.getDirectory();
const { problems, stats, pages } = await templaBuild.buildSite({
src: templaBuild.opfsStore(await root.getDirectoryHandle('src')),
dist: templaBuild.opfsStore(await root.getDirectoryHandle('dist', { create: true })),
});
if (problems.length) console.warn(problems);
</script>Load templa.js first — build.js imports its shared transforms (render,
applyConditionals, and the rest) from there, so the two targets never drift
into two implementations of the same syntax.
Loading it as a tag also auto-starts it against the builder page, as it would
against any page. On a page with no <template src> that pass finds nothing and
returns; what it does still do is mark <nav> links to the current page with
aria-current="page", which is templa's ordinary behaviour rather than a
side effect of embedding it. If you want the transforms with no DOM pass at all,
import the module instead of writing a <script> tag — auto-start keys on
document.currentScript, which an import does not set.
buildSite({ src, dist, dryRun, mergeStyles, basePath }) returns
{ problems, stats, pages } instead
of printing. problems is the same list templa check prints to the
terminal, so a non-empty array is the browser's equivalent of exit code 1;
pages lists every page path written; dryRun: true runs the same walk
without writing anything, the browser equivalent of check. It throws if the
source store's root does not exist, or if src and dist are the same store
object — every output path would equal its input path, overwriting the
source in place while reporting a clean build — since there is no process to
exit non-zero on your behalf. Pass two distinct stores. (buildSite also
accepts skipDir, used internally by the CLI to keep a build from walking
into an output directory nested inside its source; a browser embedder using
two separate roots, as above, does not need it.)
The Store interface
Storage is a seven-method interface, so OPFS is not the only option — implement
it over IndexedDB, a remote API, an in-memory Map, or anything else:
list(dir) → [{ name, isDirectory }] | null
readText(path) → string | null
writeText(path, str) → void
readBytes(path) → Uint8Array | null
writeBytes(path, bytes) → void
copy(from, to, fromStore?) → void
remove(path) → voidAll seven methods are async and take POSIX paths (/-separated) relative to
the store's own root. list returns null for anything that is not a
directory, including a path that does not exist; readText/readBytes
return null for a path that does not exist or is a directory.
Paths never escape a store's own root. nodeStore does not physically
enforce this — fs happily follows a ..-resolved path above the directory
it was rooted at — so the core does instead: a <template src> whose
resolved path starts with .. is reported by name (check/--strict gate
on it) rather than silently read on the CLI and reported as a missing partial
in memStore/opfsStore, which cannot see outside their root at all. Move
the partial inside the source root to fix it.
A build's source and destination are typically two independently-rooted
stores — the CLI's -i/-o are ordinarily sibling directories, not one
nested in the other — so cross-store copy is the normal case, not an edge
case. copy(from, to, fromStore) reads from out of fromStore (default:
the store copy is called on) and writes it to to in the store copy was
called on; readBytes/writeBytes exist so a file can move between two
differently-implemented stores without being decoded as text along the way.
Every adapter may keep a same-backend fast path, but must fall back to the
shared copyThrough helper (also exported from build.js) whenever it
cannot resolve the other store itself — that fallback is what lets a Node
store copy correctly into an OPFS store, or an OPFS store into a memory
store, without either adapter knowing the other's internals.
remove is recursive and a no-op if the path is already absent. remove('')
is a no-op too, in every adapter: a store may not delete its own root, even
though the build's only use of remove is cleaning up one directory it just
emptied.
templaBuild.storeConformance(makeStore) asserts an adapter satisfies the
full contract, including the cross-store case — run it against any new
adapter before trusting it. templaBuild also exports memStore (an
in-memory adapter, useful for tests or a build with no persistence),
opfsStore, paths (the same POSIX pjoin/pdirname/prelative/
pisAbsolute helpers the core uses internally), and copyThrough itself,
for an adapter author who needs the fallback directly.
OPFS, and what it does not verify
opfsStore(dirHandle) implements the Store contract over the Origin Private
File System.
Its automated test coverage runs the same conformance suite against a
hand-written stand-in FileSystemDirectoryHandle — real OPFS exists in
neither Node nor jsdom, so nothing in this project's npm test run touches an
actual browser. examples/opfs-check.html runs storeConformance against the
real API for a manual check. It was run against real OPFS in both Chromium
and Safari before this release and reported PASS in each, so the adapter is
known to satisfy the contract on the two engines whose OPFS implementations
differ most. That is still a manual check that nothing re-runs on change — open
it yourself on any engine you care about:
npx serve examples # then visit /opfs-check.htmlGetting the output out is your job. OPFS is origin-private storage: it is
not user-visible and nothing can browse to it directly. templa builds a
dist/ inside whatever store you hand it; turning that into something a user
or a deploy target can reach — a zip download, the File System Access API, an
upload to a host — is the embedding application's job. templa builds; it does
not deploy or publish.
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.
The two need no wiring between them. Alpine.start() installs a MutationObserver over the document and initialises whatever is added afterwards, so partials templa inserts are picked up on their own — no Alpine.initTree call, and none wanted. Register components the Alpine way, in an alpine:init listener, which runs before Alpine starts and therefore before any templa.ready you might be tempted to put it behind:
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({ open: false }));
});
</script>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 test attribute presence, exactly asdisabledandcheckeddo, so<template src="x" logged-in>is the natural spelling and omission is how you say no. - 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. - One source renders one page, identically at build time and runtime: every transform templa performs produces the same DOM on both targets. A co-located style-extraction feature broke that (the build wrote the rules to a separate stylesheet, the runtime left them inline) and was removed in 0.19.0 for exactly that reason — see the Changelog.
- 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.
