npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

templa-js

v0.25.0

Published

A tiny HTML template loader using <template src>. Read as tempura.

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 get aria-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.js by 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-js

Init

Bootstrap a new project in the current directory:

npx templa-js init           # minimal src/ tree
npx templa-js init --force   # overwrite existing files

The 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).

DOMContentLoaded is 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 — querySelector just returns null. Wait on templa.ready instead.

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, with credits passed 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 the premium attribute into the layout's source unconditionally, and conditionals test presence — so <template if="premium"> inside the footer is permanently true and its unless branch 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">&copy; 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 &lt;template slot&gt; 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

  • DOMContentLoaded fires 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, since querySelector returns null rather than throwing. Wait on templa.ready.
  • defer / async on the templa tag cost the head-expansion overlap. The page still renders; the console says so.
  • Loading templa.js in 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 on document.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 / unless are 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 / slot are special. Framework directives that merely end in one of those (Alpine's <template x-if>, Vue's <template v-if>, any data-*) 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 ./dist

The 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 → copied

An .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 .html nobody includes is a page. A half-written fragment left in the tree is written out and check reports 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. check names 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.css

Every <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 ./src

Runs 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 (&#9827;) | | <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 — &copy; &mdash; &eacute; &ldquo; &frac12; and the rest of Latin-1 — are supported and decode identically on both targets. Names are case-sensitive, as in HTML: &Eacute; is É and &eacute; 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)                  → void

All 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.html

Getting 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 use data-src. The bare src lets 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 as disabled and checked do, 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-link for 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.

License

MIT