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

@johnhenry/html-modules

v0.0.1

Published

Declarative HTML modules: export components from ordinary HTML, import them with <html-import src as>, and optionally compile them to ES modules.

Readme

html-modules

npm version CI license

Full documentation: opensource.johnhenry.me/html-modules

Declarative HTML modules for the browser. Write Web Components in ordinary HTML files, export them with <html-export>, and import them into a page with <html-import src="./ui.html" as="ui">. Each export becomes a native custom element under the import's namespace: <ui--card>, <ui--button>. There is no build step and no custom JavaScript module loader. The same modules can be used from JavaScript, mixed with JS-authored components, and optionally compiled to plain ES modules.

<script type="module" src="/node_modules/@johnhenry/html-modules/src/browser.js"></script>

<html-import src="./ui.html" as="ui"></html-import>

<ui--card>
  <h2 slot="title">Hello!</h2>
  An ordinary HTML file defined this element.
</ui--card>
<!-- ui.html -->
<html-export name="card">
  <style>:host { display: block; border: 1px solid; padding: 1rem; }</style>
  <template>
    <article>
      <header><slot name="title"></slot></header>
      <slot></slot>
    </article>
  </template>
</html-export>

This implements the PRD Declarative HTML Modules; docs/GAP.md maps each PRD section onto the code and records the deliberate extensions (<html-binding>, a configurable delimiter, the settings elements, lazy loading). The complete reference is docs/api.md. Plain JavaScript ES modules, no runtime dependencies.

Contents

Install

npm install @johnhenry/html-modules

Provenance. @johnhenry/html-modules is a new package: it was developed locally as web-module-graph and renamed before it was ever published, so 0.0.0 is the first version under any name. The unscoped html-modules on npm is an unrelated package by another author; install the scoped name. (Pre-1.0, ^0.0.0 matches only 0.0.0: pin exactly until a deliberate 0.1.0.)

  • Browsers: any browser with custom elements and shadow DOM. Constructable stylesheets are used where available; elsewhere component styles fall back to a <style> per shadow root. Nothing needs a bundler.
  • Node >= 26 (engines) for the compiler, the CLI, the tests and the Node examples. The browser code itself has no Node requirement.

Load the bootstrap once per page. Served from node_modules:

<script type="module" src="/node_modules/@johnhenry/html-modules/src/browser.js"></script>

or through an import map, which is also what lets compiled modules (which import @johnhenry/html-modules/runtime) share the page's runtime:

<script type="importmap">
  { "imports": {
      "@johnhenry/html-modules/browser": "/node_modules/@johnhenry/html-modules/src/browser.js",
      "@johnhenry/html-modules/runtime": "/node_modules/@johnhenry/html-modules/src/runtime.js"
  } }
</script>
<script type="module">import '@johnhenry/html-modules/browser';</script>

browser.js imports ./runtime.js, so both entries above resolve to one copy of the runtime. Keep it that way (see Honest limitations).

Quick start

1. Author an HTML module. Any .html file; each <html-export> is a public export, everything else is private.

<!-- ui.html -->
<html-export name="card">
  <style>:host { display: block; border: 1px solid #ddd; border-radius: 10px; padding: 1rem; }</style>
  <template>
    <article>
      <header part="title"><slot name="title"></slot></header>
      <slot></slot>
    </article>
  </template>
</html-export>

<html-export name="button" delegates-focus>
  <template><button part="button" type="button"><slot></slot></button></template>
</html-export>

<html-export name="theme"><style>:root { --brand: #5b4bd6; }</style></html-export>

<html-export name="meta"><script type="application/json">{ "version": "1.2.0" }</script></html-export>

2. Import it with <html-import as>. Every component export is registered as <as>--<export>.

<script type="module" src="/node_modules/@johnhenry/html-modules/src/browser.js"></script>
<html-import src="./ui.html" as="ui"></html-import>

3. Use the tags. Anywhere, before or after the import: elements upgrade in place when the module arrives.

<ui--card>
  <b slot="title">Hello</b>
  <ui--button>Click me</ui--button>
</ui--card>
<style>ui--card:not(:defined) { visibility: hidden; }</style>

4. Pick only what you need, choose tag names, adopt the stylesheet, read the data:

<html-import src="./ui.html" as="ui">
  <html-binding export="card"></html-binding>                          <!-- <ui--card> only -->
  <html-binding export="button" element="brand-button"></html-binding>  <!-- a tag you choose -->
  <html-binding export="theme" adopt></html-binding>                   <!-- adopted into the document -->
  <html-binding export="meta"></html-binding>                          <!-- el.bindings.meta -->
</html-import>

5. Or from JavaScript, sharing the same cache:

import { HTMLModules } from '@johnhenry/html-modules/browser';

const ui = await HTMLModules.load('./ui.html');     // { button, card, components, meta, theme }: registers nothing
ui.card.define('profile-card');                    // one definition, any number of tags
await HTMLModules.import('./ui.html', { as: 'admin' });   // = <html-import src="./ui.html" as="admin">

Runnable, self-verifying versions of all of this are in examples/ (npm run examples), and a browser demo site with live checks on every page is at /examples/ when you serve the package root (python3 -m http.server).

Writing HTML modules

An HTML module is an ordinary HTML file. Each <html-export> in it is a public export; everything else is private to the file. Export names are lower-case words joined by single hyphens (card, fancy-button).

| Export | Markup | Value | | --- | --- | --- | | Component | <html-export name="card"><template>…</template></html-export> | an HTML Component Definition | | Stylesheet | <html-export name="dark"><style>…</style></html-export> | an HTMLStylesheet | | Data | <html-export name="config"><script type="application/json">…</script></html-export> | the parsed JSON | | Default | <html-export name="default">…</html-export> (or a bare name) | the default export, like export default | | Named and default | <html-export name="card" default>…</html-export> | card, and also default, like export { card, card as default } | | Every component of another module | <html-export src="./more.html"></html-export> | like export * from | | One export of another module | <html-export src="./b.html" name="button" import="fancy-button"></html-export> | like export { fancyButton as button } from | | Several exports of another module | <html-export src="./b.html" names="card, fancy-button as button"></html-export> | like export { card, fancyButton as button } from | | Another module as a namespace | <html-export src="./icons.html" name="icon" import="*"></html-export> | like export * as icon from | | Another module's export as the default | <html-export src="./b.html" name="default" import="card"></html-export> | like export { card as default } from |

All the ESM re-export forms are covered; see Re-exports.

A component export holds exactly one <template>, which is stamped into a shadow root, so native <slot>, named slots and part work as usual. Its attributes are shadow="open" (default) or shadow="closed", and delegates-focus. <style> elements beside the template become the component's styles: one constructed stylesheet per definition, adopted by every instance's shadow root. (<style> inside the template works too, but is cloned into each instance.)

Default exports

name="default" is the default export. It mirrors JavaScript's export default, and pairs with <html-binding export="default" element="…"> on the importing side:

<html-export name="default"><template>…</template></html-export>        <!-- export default -->
<html-export name><template>…</template></html-export>                  <!-- the same: an empty name -->
<html-export name="card" default><template>…</template></html-export>   <!-- export { card, card as default } -->
  • A bare name (an empty value) also means the default. Watch out for templating: a variable that renders as name="" silently becomes a default export. Write name="default" when you mean it.
  • The default attribute is a modifier for a real name. On its own (<html-export default>) it is an error that points to name="default", and name="default" default is an error too.
  • A module has at most one default, however it is spelled. default and components cannot be named exports. A named re-export can be the default (see Re-exports); star re-exports never pass a default through, as in ESM.
  • A default-only export has no identity of its own (its definition's name is null), is not in the components manifest, and is not registered by as=: the importer names it with element=.

Re-exports

<html-export src> re-exports another module (HTML or JS), with a counterpart for every ESM form:

| ESM | HTML module | | --- | --- | | export * from "./more.html" | <html-export src="./more.html"></html-export> | | export { card } from "./b.html" | <html-export src="./b.html" name="card"></html-export> | | export { fancyButton as button } from "./b.html" | <html-export src="./b.html" name="button" import="fancy-button"></html-export> | | export { card, fancyButton as button } from "./b.html" | <html-export src="./b.html" names="card, fancy-button as button"></html-export> | | export * as icon from "./icons.html" | <html-export src="./icons.html" name="icon" import="*"></html-export> | | export { default } from "./b.html" | <html-export src="./b.html" name="default"></html-export> | | export { card as default } from "./b.html" | <html-export src="./b.html" name="default" import="card"></html-export> | | export { default as card } from "./b.html" | <html-export src="./b.html" name="card" import="default"></html-export> | | export { card, card as default } from "./b.html" | <html-export src="./b.html" name="card" default></html-export> |

  • Re-exports keep identity: barrel.button === b.fancyButton. A missing name fails with ESM's message, The requested module './b.html' does not provide an export named 'nope', and circular re-exports are rejected.
  • A namespace re-export's components also join this module's manifest as <name>--<export>, so importing the barrel with as="ui" registers <ui--icon--star>, just as <html-import as="icon"> writes <icon--star> inside a module.
  • A default re-export works like any default-only export: it is not registered by as=, and the importer names it with <html-binding export="default" element="…">.
  • Star re-exports follow export *: no default, local names win, and names two sources disagree on are left out.

Modules that import modules

Modules can import other modules with the same <html-import> element a page uses. Those imports are private to the module: they are bound when one of the module's components is registered, and a stylesheet the module adopts applies inside the module's components only.

<!-- rating.html -->
<html-import src="./icons.html" as="icon"></html-import>
<html-import src="./themes.html"><html-binding export="gold" adopt></html-binding></html-import>

<html-export name="stars">
  <template><icon--star></icon--star><icon--star></icon--star><icon--star></icon--star></template>
</html-export>

Every element and attribute, with its validation rules, is in HTML syntax.

Importing

<html-import src="./ui.html" as="ui"></html-import>

is the HTML counterpart of import * as ui from "./ui.js". as names a namespace: every component export is registered as <as>--<export>. Stylesheet and data exports are not elements and are not registered.

Asynchronous upgrade. Elements may appear before, or long after, the import that defines them. Until then they are ordinary unknown elements (style them with :not(:defined)); when the module arrives, the browser upgrades them in place. No MutationObserver is involved (except for lazy imports, which watch for their first element).

The same module can be imported under several namespaces (as="shop", as="admin"); it is fetched and parsed once. An import with no as and no bindings only loads the module, for its side effects or to warm the cache.

| <html-import> | | | --- | --- | | src | module URL: relative to the document (or its base), absolute, or a bare specifier resolved through the page's import map | | as | namespace | | delimiter | namespace delimiter for this import (default --) | | conflict | error (default) or reuse: what to do when a tag is already defined by something else | | load | eager (default) or lazy: fetch only when one of its tags is used | | errors | event (default) or throw: also reportError() failures | | type | html or js, to override detection by extension (.html/.htm are HTML; anything else is JS) | | el.ready | Promise of { module, elements, bindings, tags } once bound; rejects on failure | | el.module | Promise of the module namespace (a lazy import waits rather than loading) | | el.load() | load now, even a lazy import none of whose tags is in use; returns el.ready | | el.state | idle, waiting, loading, loaded or error | | el.elements / el.bindings / el.tags | registered tags → classes; bound export names → values; tags → { tag, namespace, export, reused? } | | el.settings / el.delimiter | the options in use, after precedence | | el.sanitize | a function (or false) that sanitizes this import's component templates: a property only (a function cannot be an attribute); see Security model | | el.src / as / type / integrity, el.delimiter / conflict / loadMode / errors | properties for the attributes (loadMode is load, whose name the method has); a script can createElement, append, then set src: it starts after the script. Changing src after loading started fires an error event | | load / error events | on the import; error has detail.error and bubbles |

Full detail: Elements (DOM API).

The configuration elements render nothing but are not hidden. <html-import>, <html-binding>, <html-import-settings> and <html-module-settings> have no default style, so inside a grid or flex container (a <body> that is display: grid, say) each one becomes a layout item and can leave a gap. Hide them: html-import, html-binding, html-import-settings, html-module-settings { display: none }. (The library adds no stylesheet of its own, which is what keeps it usable under a strict style-src.)

Namespaces and delimiters

A namespace import registers each component as <namespace><delimiter><export>. The delimiter is -- by default, and can be changed per import, for a whole document (<html-import-settings delimiter="-">), per call, or for a whole HTMLModules instance:

<html-import src="./ui.html" as="ui"></html-import>                  <!-- <ui--custom-card> -->
<html-import src="./ui.html" as="ui" delimiter="-"></html-import>    <!-- <ui-custom-card> -->

Why -- is the default. A registered tag must be a valid custom element name, or native upgrade is lost. Namespaces and export names are lower-case words joined by single hyphens, so -- can never appear inside one: every tag splits exactly one way. And -- always supplies the hyphen a custom element name needs, so one-word exports stay valid (<ui--card>). It also echoes CSS custom properties.

Other delimiters. A delimiter is one or more characters allowed in custom element names: lower-case letters, digits, -, ., _, and the non-ASCII ranges of the spec. Whitespace, upper case and characters such as : are rejected.

  • - is compact and always valid, but ambiguous: ui-custom-card could be custom-card in ui or card in ui-custom. The runtime never parses a tag back into its parts: every binding records { tag, namespace, export } (el.tags, and tags in the results of bind() and import()).
  • . reads well, but ui.card has no hyphen, so one-word exports become invalid (see Honest limitations).

Inside modules, an <html-import> uses -- unless it (or the module's own <html-import-settings>) says otherwise; the page's settings and the instance default do not apply there, so a module's templates always know the tags they were written with. Every naming function is in Names.

Selective imports: <html-binding>

The PRD specifies the whole-namespace import. As a deliberate extension, an <html-import> may have <html-binding> children. With any children, only those exports are bound, which keeps the global, permanent custom element registry free of components the page never uses.

<html-import src="./ui.html" as="ui">
  <html-binding export="card"></html-binding>                          <!-- <ui--card> only -->
  <html-binding export="button" element="brand-button"></html-binding>  <!-- a tag you choose -->
  <html-binding export="button" element="save-button"></html-binding>   <!-- the same export, again -->
  <html-binding export="default" element="tip-box"></html-binding>      <!-- a default export needs element= -->
  <html-binding export="theme" adopt></html-binding>                   <!-- a stylesheet, adopted -->
  <html-binding export="config"></html-binding>                        <!-- data, on el.bindings.config -->
</html-import>

<html-import src="./counter.js">                                     <!-- a JS module -->
  <html-binding export="Counter" element="x-counter"></html-binding>
</html-import>
  • Always write the end tag. HTML has no self-closing tags: <html-binding export="card" /> does not close the element, so the binding after it becomes its child. That is an error (a SyntaxError in a module, an error event in a page), not a silent drop, and so is any other element child of an <html-import>.
  • element= sets the tag, overriding <as>--<export>. It is the markup form of definition.define(tag).
  • adopt adopts a stylesheet export into the root that contains the import: the document, or a shadow root.
  • A binding can be added at any time, before or after its module loads; it fires load (with detail.tag, detail.element, detail.value) or error (bubbling through the import). A failing binding does not stop the others.

Identity vs registration name

A component's exported identity is not its tag. ui.html's card has the identity "card" wherever it is bound; the importer chooses the tag. A registry accepts a constructor only once, so every registration is a fresh subclass of the definition's one base element:

const ui = await HTMLModules.load('./ui.html');
ui.card.name;                          // "card"
ui.card.define('profile-card');        // one tag
ui.card.define('invoice-card');        // another; a different class, the same component
customElements.get('profile-card').component === ui.card;   // true
customElements.define('fancy-card', class extends ui.card.element { /* behaviour */ });

Defining the same definition under the same tag again is a no-op; defining a different component under a taken tag throws (or, with conflict: 'reuse', keeps the existing one).

Data binding

Templates are static by default. {{attribute}} makes a template read its host element's attributes, and props="…" on the export makes attributes reflect as typed properties:

<html-export name="user-card" props="name count:number open:boolean">
  <template>
    <h3>{{name}}</h3>
    <a href="/users/{{name}}" title="{{ name }} ({{count}} posts)">profile</a>
    <button disabled="{{off}}">Follow</button>
  </template>
</html-export>
<ui--user-card name="Ada" count="3"></ui--user-card>
<script>
  const card = document.querySelector('ui--user-card');
  card.count = 4;                    // reflects to count="4"; only the nodes that mention `count` are patched
  card.setAttribute('name', 'Grace');
</script>
  • No eval, no expression language, no innerHTML. A binding is only ever an attribute name, so it works under a strict CSP and Trusted Types. {{a + b}} is a SyntaxError.
  • Escaping. Text bindings set a text node's data: markup in a value is shown, not parsed. A URL attribute (href, src, action, …) refuses javascript: / vbscript: / HTML data: URLs (the attribute is removed). on*, style and srcdoc are never bound (a SyntaxError when the component is registered).
  • Efficient updates. The template is stamped once; an attribute change patches only the bound nodes that mention it, so the shadow root's elements are never re-created.
  • Compiled output binds identically (it passes props to the same runtime).
  • Non-goals. No loops or conditionals (use a JS component for those), no two-way binding, no object-valued props, no bindings in <template> nested in a template, none in server-rendered roots. Details and the exact rules: HTML syntax.

See it live: examples/data.html.

Form-associated components

form-associated on an export makes the component a real form control (static formAssociated and ElementInternals): it appears in FormData under its name, validates (:invalid, required, setCustomValidity()), is disabled by a disabled <fieldset>, and resets and restores with the form.

<html-export name="text-field" form-associated form-control="input">
  <template><label><slot></slot> <input></label></template>
</html-export>

<form><ui--text-field name="who" value="Ada" required>Name</ui--text-field></form>

form-control="input" names the control in the template that carries the value and the validity (and makes the component delegate focus to it by default, so a browser can focus it when validation fails); without it the element is the control (el.value = …, or el.internals.setFormValue()). attachInternals() is memoized on every template class, so a closed shadow root (which needs the internals to be found) and your own subclass share the one object the platform allows. Enter in a text-like form-control input submits the form as native implicit submission does (the form's default button, a disabled one blocking it), and form-role="submit" / "reset" make a component a submit or reset button (click, Enter and Space; it is the form's default button and the submit event's submitter; a custom element cannot be form.requestSubmit(button)'s argument, so the library fires the event itself and calls form.submit() unless it was cancelled). Not supported: non-string form values, radio-style groups, formaction and friends on a component, engines without form-associated custom elements. Reference: HTML syntax; live: examples/forms.html.

Settings

Two optional elements set defaults for one document. They are an extension beyond the PRD (which fixes -- and has no settings element).

<html-import-settings delimiter="-" base="./vendor/ui@2/" conflict="reuse" load="lazy" errors="throw"></html-import-settings>

<html-import src="./kit.html" as="ui"></html-import>                     <!-- ./vendor/ui@2/kit.html, <ui-button>, lazy, … -->
<html-import src="./icons.html" as="icon" load="eager"></html-import>   <!-- an attribute overrides the settings -->
<!-- inside an HTML module -->
<html-module-settings shadow="closed" delegates-focus></html-module-settings>
<html-export name="safe"><template>…</template></html-export>                                     <!-- closed, delegates focus -->
<html-export name="glass" shadow="open" delegates-focus="false"><template>…</template></html-export>
  • <html-import-settings> (delimiter, base, conflict, load, errors): defaults for the <html-import> elements of the document it is in. base is a base URL for resolving every relative <html-import src> (and <html-export src> re-exports, in a module), resolved against the document's own URL (not <base href>); nothing else is affected.

  • <html-module-settings> (shadow, delegates-focus): defaults for a module's component exports, baked into each definition so runtime-loaded and compiled modules agree. In a page it is an error: a page has no exports.

  • Scope is lexical. A page's settings apply only to that page's imports; a module's only to that module. Page settings and instance options never leak into modules.

  • Placement. Before every <html-import> (or <html-export>), at most one of each per document. In a page a late or second one is an error event on that element and is ignored; in a module it is a SyntaxError.

  • Invalid values are errors that list the valid values. In a page, an invalid <html-import-settings> fails every import of that page rather than letting them run with other options.

  • Precedence, for each option:

    | | Pages | Inside modules | | --- | --- | --- | | 1 | the <html-import> attribute | the <html-import> attribute | | 2 | the document's <html-import-settings> | the module's <html-import-settings> | | 3 | createHTMLModules({ delimiter, base, conflict, load, errors }) | (never) | | 4 | built-in defaults: --, no base, error, eager, event | built-in defaults |

    The JavaScript API (HTMLModules.import(), bind()) is not in any document: its call options override the instance options, and document settings do not apply to it.

The full rules are in HTML syntax.

Lazy loading

With load="lazy" (on an import, or for a document in <html-import-settings>), nothing is fetched until one of the import's tags is actually used. Then the module loads, its tags are registered, and the waiting elements upgrade in place.

<html-import-settings load="lazy"></html-import-settings>
<html-import src="./ui.html" as="ui"></html-import>                  <!-- fetched when the first <ui--…> appears -->
<html-import src="./tip.html">                                     <!-- fetched when a <my-tip> appears -->
  <html-binding export="default" element="my-tip"></html-binding>
</html-import>
  • What it waits for. A namespace import waits for any tag starting with <as><delimiter>; an import with <html-binding> children waits for exactly the tags they bind. An import with nothing to wait for (no as, or only adopt, data and default-without-element bindings) could never load, so it is an error (<html-import src="…"> is lazy but has no tag to wait for); write load="eager".
  • Where it looks. The document (one MutationObserver per window, plus a scan of what is already there), and every shadow root created by an html-modules component, open or closed. What it does not see is listed under Honest limitations.
  • API. el.ready resolves when the module has been loaded and bound; el.load() forces loading; el.module waits; el.state is waiting until then. Disconnecting a waiting import cancels the watching; reconnecting resumes it.
  • In modules, a module's own lazy import loads when one of its tags first appears (typically in one of its components' shadow roots). A lazy module import cannot adopt a stylesheet.
  • JavaScript. HTMLModules.import(src, { as, load: 'lazy' }) returns a handle right away instead of a promise: { ready, load(), cancel(), state }.
  • The compiler does not lazy-load: compiled dependencies are static imports.

Full detail: Lazy loading.

Scoped registries

Where the browser has scoped custom element registries (new CustomElementRegistry() and attachShadow({ customElementRegistry })), a module's own imports can register into a registry of their own instead of the global one, so two versions of a library that both use <icon--star> inside their components coexist on one page:

<!-- lib.html: its imports are private to the registry its components' shadow roots use -->
<html-import-settings registry="scoped"></html-import-settings>
<html-import src="./icons.html" as="icon"></html-import>

registry="scoped" works on a module's <html-import> or <html-import-settings> (never on a page: a page's tags live in the document's registry). Where it is unsupported, supportsScopedRegistries(window) says so, and a scoped import falls back to the global registry with one console warning. The cross-browser CI found Chromium 153 and macOS WebKit 26.6 supporting scoped registries and Firefox 155 and the Linux WebKit build (26.6) not, so on those the page reports "unsupported" instead of failing. Details: HTML syntax; live: examples/scoped.html.

JavaScript API

@johnhenry/html-modules/browser defines the elements and exposes the shared instance as HTMLModules (exported, and on globalThis). <html-import> is a thin layer over it, so both share one cache and one set of rules. The side-effect-free root entry point, @johnhenry/html-modules, exports everything else.

import { HTMLModules } from '@johnhenry/html-modules/browser';

const ui = await HTMLModules.load('./ui.html');
// { card, button, theme, config, default, components }: shaped like a compiled ES module

await HTMLModules.import('./ui.html', { as: 'ui' });                                    // = <html-import as="ui">
await HTMLModules.import('./ui.html', { as: 'ui', delimiter: '-' });                    // = <html-import as="ui" delimiter="-">
await HTMLModules.import('./ui.html', { as: 'ui', conflict: 'reuse', errors: 'throw' });
const lazy = HTMLModules.import('./ui.html', { as: 'ui', load: 'lazy' });               // a handle: { ready, load(), cancel(), state }
await HTMLModules.import('./ui.html', { bindings: [{ export: 'card', element: 'x-card' }] });
HTMLModules.bind(ui, { as: 'admin' });                                                  // bind a loaded module
HTMLModules.resolve('./ui.html');                                                       // → absolute URL
HTMLModules.cache;                                                                      // Map<`<kind>:<URL>`, Promise<namespace>>
HTMLModules.unload('./ui.html');                                                        // evict it from the cache: the next load fetches again

A loaded namespace is frozen and has a null prototype. Named exports are camelCase (fancy-button → fancyButton), default is present when the module has one, and components maps export names to component definitions.

The API at a glance; every entry links to its reference:

| Area | Exports | Reference | | --- | --- | --- | | Instances | createHTMLModules(options), the instance's load, unload, import, bind, resolve, cache, options, base; defineHTMLModuleElements() | JavaScript API | | Elements | HTMLImport, HTMLBinding, HTMLExport, HTMLImportSettings, HTMLModuleSettings (from /browser) | Elements | | Definitions | defineHTMLComponent, HTMLComponent, defineHTMLStylesheet, HTMLStylesheet, isHTMLComponent, isHTMLStylesheet, isStylesheet, isElementLike | Runtime | | Binding and registration | bindModule, applyBinding, registerComponents, defineElement, toComponent, lookupExport, componentsOf, manifest, adoptStylesheet | Runtime | | Module records | readHTMLModule, scanHTMLModule, recordFromRaw, moduleImportOptions | Records | | Settings | IMPORT_DEFAULTS, EXPORT_DEFAULTS, readImportSettings, readModuleSettings, readImportOptions, resolveImportOptions | Records | | Names | DELIMITER, bindingName, parseBindingName, isValidDelimiter, isValidElementName, elementNameProblem, isKebabName, camelCase, kebabCase | Names | | Lazy loading | lazyTargets, watchLazy, componentRoot | JavaScript API | | Loader | createLoader, linkHTMLModule, createNamespace | JavaScript API | | Sanitizing templates | the sanitize option (createHTMLModules, import, load, <html-import>.sanitize, HTMLModules.sanitize); safeFragmentSanitizer, registerTemplateProfile from /safe-fragment | Sanitizing templates | | Compiler | compileHTMLModule, compileRecord, rewriteSpecifier, rebaseSpecifier, the html-module CLI | Compiler |

Definitions (src/runtime.js, also @johnhenry/html-modules/runtime) are the shared representation. The loader and the compiler both produce them with defineHTMLComponent():

import { defineHTMLComponent } from '@johnhenry/html-modules/runtime';

const card = defineHTMLComponent({
  name: 'card',                        // identity
  template: '<article><slot></slot></article>',
  shadow: 'open',                      // or 'closed'
  delegatesFocus: false,
  styles: [':host { display: block }'],
  imports: [],                         // [{ module, from, as?, bindings? }] bound before registration
});
card.define('my-card');                // → the registered class
card.element;                          // the base class, to extend

JavaScript-authored components

<html-import> takes JS modules too, loaded with native import(). A JS module says which exports are components; nothing else is ever registered, so export const VERSION = "2.1" never becomes <ui--version>.

// ui.js: either a components manifest …
export const components = { 'custom-card': CustomCard, 'fancy-button': FancyButton };

// … or exports made with defineHTMLComponent()
export const customCard = defineHTMLComponent(CustomCard);
<html-import src="./ui.js" as="ui"></html-import>   <!-- <ui--custom-card>, <ui--fancy-button> -->

A plain class needs no manifest when the page names it: <html-binding export="Counter" element="x-counter">. To give an HTML template JavaScript behaviour, extend its definition's element and export the result:

const { likeView } = await HTMLModules.load(new URL('./like-view.html', import.meta.url).href);
export class LikeButton extends likeView.element { connectedCallback() { /* … */ } }
export const components = { 'like-button': defineHTMLComponent({ element: LikeButton, imports: likeView.imports }) };

HTML modules can re-export JS components too: <html-export src="./widgets.js" name="counter" import="Counter">.

The compiler

Browsers cannot import an .html file, and this library does not try to make them (no service worker, no custom loader). Instead, an optional compiler turns an HTML module into an ordinary ES module:

npm install --save-dev @johnhenry/html-modules
npx html-module ui.html                                              # → ui.js
npx html-module ui.html -o dist/ui.js
npx html-module a.html b.html --runtime ./vendor/html-modules/runtime.js
npx html-module ui.html --format register --as ui                    # → ui.register.js, registers <ui--…> on import
npx html-module ui.html --format register --as ui --delimiter -      # registers <ui-…>
npx html-module ui.html --format register --as ui --conflict reuse   # keeps tags that are already defined
npx html-module ui.html --stdout

(Without installing first: npx -p @johnhenry/html-modules html-module ui.html. A bare npx html-module outside a project that has the package installed would look for an npm package named html-module, which is not this one.)

import { compileHTMLModule } from '@johnhenry/html-modules/compiler';
const js = compileHTMLModule(source, { url: 'ui.html' });                       // runtime: "@johnhenry/html-modules/runtime"
const reg = compileHTMLModule(source, { format: 'register', as: 'ui', delimiter: '-' });

The output imports only the runtime and exports the same definitions the runtime loader would build:

// ui.js (abridged)
import { defineHTMLComponent, manifest } from "@johnhenry/html-modules/runtime";
const $x_card = defineHTMLComponent({ name: "card", template: "<article>…</article>", shadow: "open", … });
const $components = manifest({ "card": $x_card }, []);
export { $x_card as card, $components as components };
  • Nothing registers on import. import { card } from './ui.js'; card.define('my-card'), or <html-import src="./ui.js" as="ui">, which reads the components manifest. --format register is the sugar that registers every component on import (under --as, or under the export names, which must then be valid custom element names).
  • Default exports compile to export default.
  • Dependencies (<html-import>, <html-export src>) become static imports with .html rewritten to .js, so compile those modules too. A module's <html-import-settings base> is applied first, keeping specifiers relative. Star re-exports become export * from.
  • Settings. <html-module-settings> defaults are baked into each definition; <html-import-settings> options are carried into $imports, which the runtime binds with. load="lazy" does not make compiled code lazy.
  • The compiler reads source with a small dependency-free scanner that follows the HTML tokenizer (comments, raw-text elements, script escapes, character references, end-tag scoping) and produces the same module record as the browser's DOM reader. It is tested against every example and, with a seeded fuzz, against parse5. Pass parse to use a DOM parser instead.
  • Runtime-loaded and compiled modules render identically; the test suite and the compiler example check this.

Every option, the CLI flags and exit codes, and a full generated module: Compiler and CLI.

Dev server, hot reload and Vite

html-module dev ./site          # serve a directory, watch it, hot reload open pages (no dependencies)

html-module dev [dir] is a static server over node:http and fs.watch that pushes file changes to open pages over Server-Sent Events. When an HTML module changes, the page calls HTMLModules.hotReload(url): the module is fetched again and its components and styles are swapped under live elements. Custom element definitions cannot be redefined, so registered classes delegate to a swappable definition: a template change re-stamps each element's shadow root in place (host, listeners and light DOM are kept; bindings re-bound) and a style change swaps the adopted stylesheet. A change that cannot be applied to live elements (exports added or removed, a changed shadow mode, form-associated, new observed attributes, changed imports) reloads the page; a module with a syntax error changes nothing and shows an overlay. See Dev server, hot reload and Vite.

For modules imported from JavaScript there is a Vite plugin, with the same in-place HMR:

// vite.config.js
import htmlModules from '@johnhenry/html-modules/vite';
export default { plugins: [htmlModules()] };

// main.js
import { customCard } from './ui.html';   // compiled with the same compiler as the CLI
customCard.define('x-card');

vite is a dev dependency of this package only; npm install @johnhenry/html-modules adds no runtime dependency.

TypeScript

Declarations ship for every entry point (., ./browser, ./runtime, ./compiler, ./dev, ./vite), generated from the source's JSDoc, with a types condition in package.json's exports. They include the record types (ModuleRecord, ImportRecord, ExportRecord), module namespaces, the HTMLModules instance and the element classes:

import { scanHTMLModule, type ModuleRecord } from '@johnhenry/html-modules';
import { HTMLModules, type HTMLImportElement } from '@johnhenry/html-modules/browser';

A strict typed consumer of every entry point is compiled by npm run types:check (in npm test and CI); npm run types regenerates types/. See TypeScript in the API reference.

Testing in real browsers

linkedom (the unit-test DOM) has no constructable stylesheets, Trusted Types or custom-element upgrade semantics inside shadow roots (a bug in CSSStyleSheet's baseURL once passed every unit test and failed in Chrome), so anything about styles, shadow DOM, registries, forms or security is proven in real engines:

npx playwright install --with-deps    # once
npm run test:browser                  # Chromium, Firefox and WebKit
npx playwright test --project=webkit  # one engine

test/browser/ drives every examples/*.html page (each renders pass/fail checks; none may fail and nothing may log an error) and targeted specs: constructable stylesheets and url() resolution against the module, declarative shadow DOM (open and closed), Trusted Types under an enforced require-trusted-types-for CSP (scripts/test-server.js adds CSP headers on request), data binding, form association, hot reload against a real html-module dev server and a real Vite dev server, and scoped registries. A feature an engine lacks is reported by the page as unsupported, never as a failure. The CI browsers job runs all three engines; what it found (201 specs, Chromium 153, Firefox 155, WebKit 26.6 on Linux):

| Feature | Chromium | Firefox | WebKit | | --- | --- | --- | --- | | everything else (constructable sheets, declarative shadow DOM, form-associated custom elements, :state(), Trusted Types, data binding, hot reload, Vite HMR) | passes | passes | passes | | scoped custom element registries (registry="scoped") | supported | unsupported (page says so; imports fall back with a warning) | unsupported in the Linux build CI uses (supported in WebKit 26.6 on macOS, where it was also run) | | form state restored on history navigation | restored | the engine restored not even a plain form-associated element in an automated back navigation, so the page reports those checks as unsupported | restored |

(Firefox cannot be launched in the sandbox the maintainer's agent runs in, so it is exercised only in CI; Chromium and WebKit also run locally.) Under a strict style-src no engine reports a CSP violation for a module's <style>: the loader parses a module into a detached element rather than a DOMParser document (Chromium CSP-checks the <style> elements of a document, one style-src-elem report each), and test/browser/parse.spec.js checks in each engine that the record is identical to the DOMParser one. A style="…" attribute in a module's markup is a style-src-attr report whichever way it is parsed (and is blocked when stamped), so keep inline style attributes out of modules.

Benchmarks

npm run bench (non-gating; --json for machine output): the scanner on a 500-component, 160 KiB module, the compiler, and, in each Playwright engine that launches, registering 500 components (fetch, parse, bind and define) and stamping one element of each. One run each (numbers move around, CI runners most):

| | Mac (arm64), Node 24 | CI (linux/x64), Node 26 | | --- | --- | --- | | scanHTMLModule, 500 components | 4.4 ms (≈ 38 MB/s) | 6–9 ms (≈ 18–27 MB/s) | | compileHTMLModule (scan + codegen) | 4.4 ms | 5–10 ms | | readHTMLModule (DOM reader over linkedom, parse excluded) | 4.1 ms | 5–8 ms | | scan a 10-component module | 0.08 ms | 0.07–0.12 ms | | register 500 components: Chromium / Firefox / WebKit | 28 ms / n.a. / 34–62 ms | 67–170 ms / 72–102 ms / 41–70 ms | | create and stamp 500 elements: Chromium / Firefox / WebKit | 11 ms / n.a. / 15–21 ms | 16–31 ms / 32–82 ms / 18–43 ms | | patch one bound attribute: Chromium / Firefox / WebKit | 0.9 µs / n.a. / 1.1 µs | 1.5–2.0 µs / 1.5–3.6 µs / 1.1–1.4 µs |

Resolution and caching

  • src resolves like a module specifier: relative to the importing document (or, inside a module, the importing module), or absolute. Bare specifiers go to hostResolve; in the browser that is import.meta.resolve, so the page's own <script type="importmap"> applies. Import maps only map URLs; they never parse HTML. As in JavaScript, ui.html without ./ is a bare specifier.
  • A document's <html-import-settings base> replaces the document (or module) URL as the base for its relative specifiers.
  • Modules are cached by kind (HTML or JavaScript) and resolved URL as promises, so repeated and concurrent imports share one fetch and parse. Failed loads are evicted and can be retried; HTMLModules.unload(src) evicts one on purpose (registered tags stay registered, and a JavaScript module stays in the browser's module map).
  • Circular dependencies between HTML modules are rejected with the cycle in the message, whether the modules load one after another or concurrently.

Errors

Nothing fails silently. In markup, a failure is an error event (bubbling, composed, with detail.error) on the element concerned, and a rejected el.ready; a failing <html-binding> fires on the binding and bubbles through its import. In JavaScript it is a throw or a rejection. With errors="throw" it also reaches reportError() (the console and window.onerror). Mistakes in a module's source are SyntaxErrors naming the module, identical whether the module is loaded or compiled. Some common ones:

| Situation | Error | | --- | --- | | Module fetch fails | Error: Failed to fetch HTML module …: 404 | | Invalid module (no template/style/JSON, duplicate or non-kebab name, two templates, bad shadow) | SyntaxError naming the module | | A tag the delimiter makes invalid (as="ui" delimiter="." + card) | SyntaxError: … <ui.card> is not a valid custom element name (it has no hyphen) | | Circular dependency | Error: Circular HTML module dependency: a -> b -> a | | Tag already bound to a different component | Error: Cannot bind <ui--card>: it is already defined by "card" from … (conflict="reuse" keeps the existing definition instead) | | Missing export | SyntaxError: The requested module '…' does not provide an export named '…' | | <html-import-settings> after an <html-import> | SyntaxError: <html-import-settings> must come before any <html-import> … |

Every error, its exact message and where it is reported: Errors.

Non-goals

From the PRD: no custom JavaScript module loader; no direct import … from "./ui.html" in JavaScript (use HTMLModules.load() or the compiler); no service workers; no bundler requirement; no framework; import maps are not responsible for HTML. Package and CDN routing (version ranges, mirrors, lockfiles) is out of scope: use @johnhenry/mport, and point a page import map at what it resolves (see Family).

Deferred PRD items (HTML Include, further export metadata, a bundle compiler format) are listed with reasons in docs/GAP.md.

Project layout

src/
  names.js         export names, namespaces and delimiters
  settings.js      the settings vocabulary, validation and precedence
  runtime.js       HTML Component Definitions → custom elements; binding (shared by runtime and compiled code)
  record.js        module records; readHTMLModule() from a DOM
  types.js         JSDoc typedefs (type definitions only)
  template.js      data binding: {{attribute}} sites, props, URL escaping
  form.js          form-associated components (ElementInternals)
  dev-server.js    html-module dev: static server, fs.watch, SSE (and dev-client.js, the page half)
  vite.js          the Vite plugin
  scan.js          scanHTMLModule() from source text
  loader.js        resolve, fetch, parse, cache, link dependencies; runs the `sanitize` hook over component templates
  safe-fragment.js the optional adapter from @johnhenry/safe-fragment to `sanitize` (@johnhenry/html-modules/safe-fragment)
  lazy.js          lazy loading: what an import waits for, and the watcher
  html-modules.js  createHTMLModules(): load / import / bind
  elements.js      <html-import>, <html-binding>, <html-export>, and the settings elements
  compiler.js      compileHTMLModule()
  index.js         the side-effect-free root entry point
  browser.js       the one-script bootstrap
types/             generated .d.ts files (npm run types), shipped
bin/html-module.js the compiler CLI
examples/          numbered Node examples (npm run examples) and the browser demo site (open /examples/)
test/              node:test suites (npm test), with linkedom as the test DOM
docs/api.md        the API reference (docs/api/*.md)
docs/GAP.md        the PRD mapped onto the code
npm test                    # node:test, with linkedom as the test DOM
npm run check               # every source file parses; entry points import
npm run examples            # the numbered Node examples, each self-verifying
npm run examples:compile    # regenerate examples/compiled/
npm run test:browser        # Playwright: Chromium, Firefox, WebKit
npm run types               # regenerate the .d.ts files; npm run types:check compiles a typed consumer
npm run bench               # non-gating benchmark

Adding a new export kind

An HTML module has four export kinds: component (a <template>), stylesheet (only <style>), data (one JSON <script>) and re-export (src). The export kind is this library's real extension point: elements, naming and binding are fixed by the PRD, but what an <html-export> can hold is decided in one function and consumed by two back ends. The stylesheet kind is the best worked example in the package's history (it shipped with the PRD implementation in e597271), because it is the one kind that needed its own runtime value, its own binding behaviour (adopt) and its own exclusion from registration, all of which a new kind may need.

Smallest: a new attribute or payload on an existing kind. record.js's exportRecord() is a chain of branches by children shape; a new component attribute with concrete semantics (as delegates-focus was) is one more field in the component branch, passed through defineHTMLComponent() by the loader and emitted by the compiler. A new JSON MIME type is a change to one regular expression. And if the new thing is just a value, it is already a data export: no new kind. The test that separates the two: does the value need its own runtime type that bindings must treat differently (registered, adopted, or refused), which a JSON value cannot express?

A genuinely new kind. Every existing kind follows one pattern, so a new one does too:

  1. src/record.js: a branch in exportRecord() that recognizes the new children shape and returns { kind: '<x>', name, default?, … } (copy the stylesheet branch), plus its entry in the ExportRecord typedef. Its validation errors are SyntaxErrors built with describe(raw) and where, like every other.
  2. src/scan.js, only if the payload lives somewhere the scanner does not already capture (it keeps a direct child's text for raw-text elements and html for <template>). rawOf() in record.js is the DOM-side equivalent and must capture the same thing.
  3. src/runtime.js: a value class and define<X>() (copy HTMLStylesheet / defineHTMLStylesheet), branded with a Symbol.for('html-modules.<x>') so copies of the library recognize each other, an is<X>() predicate, and a label in kindOf() so binding errors name the kind. If bindings treat it specially, that is a branch in applyBinding() (as adopt is for stylesheets). manifest() and componentsOf() exclude non-element values already.
  4. The one part that isn't boilerplate: the two back ends must build the same value. linkHTMLModule() in src/loader.js (runtime) and compileRecord() in src/compiler.js (compiled output) each have a switch (e.kind); add the case to both, and add the new define<X> to the compiler's helper imports. The module record is the contract between them: neither back end looks at HTML, so if the record is right and both cases construct the value from the same record fields, runtime-loaded and compiled modules cannot disagree. The <html-export> element itself needs nothing: exports are read from a module's source, never executed in place.

Tests, all against local files and linkedom, no browser or network: a record test in test/format.test.js (both readers, and every new error message with the module URL), a case in test/compiler.test.js's "runtime and compiled definitions are equivalent", and an example module using the new kind under examples/components/, which test/examples.test.js automatically runs through the DOM reader / scanner agreement check and the loader. Add a line to examples/04-invalid-modules-fail-with-a-named-syntax-error.mjs for each new error.

Contrast with @johnhenry/fileable's "Adding a new tag" section, where a new tag relabels itself so the rest of the pipeline never learns it exists. Here the opposite holds: both back ends must learn the new kind, and the shared record, plus the equivalence test, is what keeps them in step.

Honest limitations

  • Custom element names are global and permanent; scoped registries only help inside modules, where supported. Once a tag is defined in a window it cannot be undefined or redefined: removing an <html-import> unregisters nothing (it only un-adopts the stylesheets its adopt bindings adopted), and a second version of a library needs its own namespace (or conflict="reuse", which keeps the first). registry="scoped" in a module lets two versions use the same inner tags (see Scoped registries), but the tags a page uses are always global, and the option needs a browser with scoped registries (it falls back, with a warning, elsewhere).
  • Lazy loading only sees trees it can observe. It watches the document and the shadow roots html-modules itself creates (open or closed). A tag used inside a shadow root made by other code (a JS component's own attachShadow()), in another document (an iframe), in <template> content not yet cloned into a watched tree, or in an element created but never inserted does not trigger the load. Call el.load() (or the handle's load()) for those; this is a property of MutationObserver, which cannot see into shadow roots it was not given.
  • A runtime copy and a compiled copy of the same module conflict. Loading ui.html at runtime and importing a compiled ui.js gives two different definitions, so registering both under the same tags fails with Cannot bind <…>: it is already defined … unless one import says conflict="reuse" (the first definition wins) or they use different namespaces. Relatedly, load one copy of the runtime per page: two copies of runtime.js at different URLs (say, the page's from node_modules and a compiled module's from a CDN) still recognize each other's definitions, but keep separate registration bookkeeping, lazy-loading watchers and stylesheet caches, so conflict messages lose the "defined by" detail and lazy imports stop seeing the other copy's shadow roots. Map @johnhenry/html-modules/runtime to the same file the bootstrap uses (Install).
  • Relative URLs in a template resolve against the page. A module's <style> resolves url(...) against the module (html-modules rewrites relative url()s to absolute ones against the module's URL), but its <template> is stamped into the page, so <img src="./logo.png"> is relative to the page, not the module, and html-modules does not rewrite it. Use absolute URLs for assets of a module served from elsewhere. @import in a <style> is rejected (constructed stylesheets drop it silently).
  • Server-side rendering is supported by declarative shadow DOM, not by running the library on the server. renderDeclarative(def, innerHTML) returns the <template shadowrootmode> markup for a component (styles included) to put inside its host tag; when the element upgrades, the runtime keeps that shadow root (open or closed), adopts the component's sheets and does not stamp the template again. It does not render nested components or run any script. A shadow="closed" component's base class calls attachInternals() (to see a closed declarative root); the call is memoized, so a subclass can call this.attachInternals() and gets the same object.
  • The . (and _) delimiter cannot name one-word exports. . is a legal custom element name character, but ui.card has no hyphen, so binding a one-word export under delimiter="." is a SyntaxError naming the tag; a namespace import checks every tag before registering any, so it fails whole. Two-word exports (ui.custom-card) work. - always works but makes tags ambiguous to read back (the runtime records { tag, namespace, export } instead of parsing). -- has neither problem, which is why it is the default.

Security model

html-modules is a loader and a registrar, not a sandbox. It fetches markup you point it at, validates its shape, and turns it into custom elements; whatever that markup (or a JavaScript module you import) does in the page is done with your page's authority.

What html-modules guarantees:

  • Only declared exports are exports. readHTMLModule() / scanHTMLModule() collect <html-export>, <html-import> and the settings elements, and recordFromRaw() rejects anything else a module could smuggle in (nesting, a second template, data mixed with a template). Everything else in the file is never evaluated: a module is parsed into a detached element of an inert, script-less DOMParser document (never connected, so nothing loads), and a <script> in a module does not run.
  • JSON data is data. A data export is read with JSON.parse (and compiled to JSON.parse(...)), so a "__proto__" key is an own key and never reaches a prototype.
  • Fetched HTML can be pinned. integrity (an <html-import integrity> attribute, or the integrity option of HTMLModules.load() / import()) is Subresource Integrity metadata, checked with crypto.subtle.digest against the bytes actually received: the strongest algorithm listed decides, and a mismatch rejects with Integrity check failed for HTML module <url> and is not cached. It fails closed: without crypto.subtle the load is refused. A whole graph can be pinned in one place with an integrity manifest, and a JavaScript import is pinned through the page's import map.
  • Requests carry what you configure. credentials and mode (createHTMLModules() options, overridable per load()) are passed to fetch() for HTML modules; by default html-modules adds nothing to the platform's defaults.
  • Registration is all or nothing and never silent. Tags are checked before any is registered, an existing tag is never redefined, and every failure is an error event, a rejection or a throw (Errors).
  • Templates from a less-trusted origin can be sanitized, opt in. sanitize is a function every component template passes through at load time, before any component is defined, so a module can be loaded with its <img onerror>, javascript: links, <iframe srcdoc>, handlers and <script> removed. html-modules ships the hook and an adapter for @johnhenry/safe-fragment; it fails closed (a sanitizer that throws means the module does not load) and it is off unless you set it.
  • Trusted Types and CSP are supported. The two HTML sinks (innerHTML on a detached element for fetched modules, template.innerHTML for a component's template) go through a trustedTypes policy you pass, or a policy named html-modules; a nonce option covers the <style> fallback. html-modules inserts no <script> and uses no eval. See Trusted Types and CSP.

What is still yours:

  • A module URL is as trusted as a <script src>, unless you opt in to sanitizing it. By default templates are stamped into the page as real DOM, so a module you import can do what its markup can do: <img src=x onerror="…">, <a href="javascript:…">, <iframe srcdoc="…"> and inline event handlers all execute (subject to your CSP). Import only modules you would load as a script, from origins you control or have pinned with integrity, or sanitize their templates; do not build a module's URL or source from user input.
  • The sanitizer covers templates, and nothing else. A module's <html-export><style> stylesheets are not given to it (CSS can still leak state through url() requests and redress the UI), compiled output has no load step to hook, a sanitized module's JavaScript imports are refused rather than vetted, and what your own scripts later put in a shadow root is not sanitized. A sanitizer is only as good as its function: html-modules does not check what it returns beyond its type.
  • Importing JavaScript runs code. <html-import src="./x.js"> is a native import(): the module's top level runs with full page authority, and html-modules cannot fetch it to hash it. The supported path is the page's import map integrity, which the browser enforces for import(); an integrity attribute on the import is checked against it (Pinning JavaScript imports). Without one, use a CSP script-src allowlist.
  • integrity covers only what you give it. An attribute pins one fetch; a re-export or dependency without its own integrity, or an entry in the manifest, is fetched unverified unless you turn on strict. The manifest is yours to keep current, and compiled output is ordinary JavaScript: pin it as you pin any script.
  • CORS, cookies and CSP are the platform's. credentials: 'include' sends cookies to whatever origin the module is on; html-modules does not add or relax any CORS check, and connect-src / script-src decide what may be fetched or imported.
  • Rendering untrusted data into a template is yours. Templates are static markup; html-modules does not sanitize what your own scripts later put into a component's shadow DOM.

Pinning a whole graph: the integrity manifest

integrity on an import pins one fetch. To pin a graph (a module, what it imports, what it re-exports) in one place, give createHTMLModules() an integrity manifest: an object of absolute URL to Subresource Integrity metadata, the same shape as the integrity object of an import map.

import { createHTMLModules, defineHTMLModuleElements } from '@johnhenry/html-modules';

const modules = createHTMLModules({
  integrity: {
    'https://cdn.example/ui/kit.html': 'sha384-…',