@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.
Maintainers
Readme
html-modules
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
- Quick start
- Writing HTML modules
- Importing
- Namespaces and delimiters
- Selective imports:
<html-binding> - Identity vs registration name
- Data binding
- Form-associated components
- Settings
- Lazy loading
- Scoped registries
- JavaScript API
- JavaScript-authored components
- The compiler
- Dev server, hot reload and Vite
- TypeScript
- Testing in real browsers
- Benchmarks
- Resolution and caching
- Errors
- Non-goals
- Project layout
- Adding a new export kind
- Honest limitations
- Security model
- Family
- License
Install
npm install @johnhenry/html-modulesProvenance. @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 asname=""silently becomes a default export. Writename="default"when you mean it. - The
defaultattribute is a modifier for a real name. On its own (<html-export default>) it is an error that points toname="default", andname="default" defaultis an error too. - A module has at most one default, however it is spelled.
defaultandcomponentscannot 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
nameisnull), is not in thecomponentsmanifest, and is not registered byas=: the importer names it withelement=.
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 withas="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 *: nodefault, 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-cardcould becustom-cardinuiorcardinui-custom. The runtime never parses a tag back into its parts: every binding records{ tag, namespace, export }(el.tags, andtagsin the results ofbind()andimport())..reads well, butui.cardhas 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 (aSyntaxErrorin a module, anerrorevent 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 ofdefinition.define(tag).adoptadopts 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(withdetail.tag,detail.element,detail.value) orerror(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, noinnerHTML. A binding is only ever an attribute name, so it works under a strict CSP and Trusted Types.{{a + b}}is aSyntaxError. - Escaping. Text bindings set a text node's
data: markup in a value is shown, not parsed. A URL attribute (href,src,action, …) refusesjavascript:/vbscript:/ HTMLdata:URLs (the attribute is removed).on*,styleandsrcdocare never bound (aSyntaxErrorwhen 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
propsto 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.baseis 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 anerrorevent on that element and is ignored; in a module it is aSyntaxError.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 (noas, or onlyadopt, data and default-without-elementbindings) could never load, so it is an error (<html-import src="…"> is lazy but has no tag to wait for); writeload="eager". - Where it looks. The document (one
MutationObserverper 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.readyresolves when the module has been loaded and bound;el.load()forces loading;el.modulewaits;el.stateiswaitinguntil 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
adopta 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 againA 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 extendJavaScript-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 thecomponentsmanifest.--format registeris 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.htmlrewritten to.js, so compile those modules too. A module's<html-import-settings base>is applied first, keeping specifiers relative. Star re-exports becomeexport * 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
parseto 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 enginetest/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
srcresolves like a module specifier: relative to the importing document (or, inside a module, the importing module), or absolute. Bare specifiers go tohostResolve; in the browser that isimport.meta.resolve, so the page's own<script type="importmap">applies. Import maps only map URLs; they never parse HTML. As in JavaScript,ui.htmlwithout./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 codenpm 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 benchmarkAdding 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:
src/record.js: a branch inexportRecord()that recognizes the new children shape and returns{ kind: '<x>', name, default?, … }(copy the stylesheet branch), plus its entry in theExportRecordtypedef. Its validation errors areSyntaxErrors built withdescribe(raw)andwhere, like every other.src/scan.js, only if the payload lives somewhere the scanner does not already capture (it keeps a direct child'stextfor raw-text elements andhtmlfor<template>).rawOf()inrecord.jsis the DOM-side equivalent and must capture the same thing.src/runtime.js: a value class anddefine<X>()(copyHTMLStylesheet/defineHTMLStylesheet), branded with aSymbol.for('html-modules.<x>')so copies of the library recognize each other, anis<X>()predicate, and a label inkindOf()so binding errors name the kind. If bindings treat it specially, that is a branch inapplyBinding()(asadoptis for stylesheets).manifest()andcomponentsOf()exclude non-element values already.- The one part that isn't boilerplate: the two back ends must build the same value.
linkHTMLModule()insrc/loader.js(runtime) andcompileRecord()insrc/compiler.js(compiled output) each have aswitch (e.kind); add the case to both, and add the newdefine<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 itsadoptbindings adopted), and a second version of a library needs its own namespace (orconflict="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. Callel.load()(or the handle'sload()) for those; this is a property ofMutationObserver, which cannot see into shadow roots it was not given. - A runtime copy and a compiled copy of the same module conflict. Loading
ui.htmlat runtime and importing a compiledui.jsgives two different definitions, so registering both under the same tags fails withCannot bind <…>: it is already defined …unless one import saysconflict="reuse"(the first definition wins) or they use different namespaces. Relatedly, load one copy of the runtime per page: two copies ofruntime.jsat different URLs (say, the page's fromnode_modulesand 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/runtimeto the same file the bootstrap uses (Install). - Relative URLs in a template resolve against the page. A module's
<style>resolvesurl(...)against the module (html-modules rewrites relativeurl()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.@importin 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. Ashadow="closed"component's base class callsattachInternals()(to see a closed declarative root); the call is memoized, so a subclass can callthis.attachInternals()and gets the same object. - The
.(and_) delimiter cannot name one-word exports..is a legal custom element name character, butui.cardhas no hyphen, so binding a one-word export underdelimiter="."is aSyntaxErrornaming 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, andrecordFromRaw()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-lessDOMParserdocument (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 toJSON.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 theintegrityoption ofHTMLModules.load()/import()) is Subresource Integrity metadata, checked withcrypto.subtle.digestagainst the bytes actually received: the strongest algorithm listed decides, and a mismatch rejects withIntegrity check failed for HTML module <url>and is not cached. It fails closed: withoutcrypto.subtlethe 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.
credentialsandmode(createHTMLModules()options, overridable perload()) are passed tofetch()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
errorevent, a rejection or a throw (Errors). - Templates from a less-trusted origin can be sanitized, opt in.
sanitizeis 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 (
innerHTMLon a detached element for fetched modules,template.innerHTMLfor a component's template) go through atrustedTypespolicy you pass, or a policy namedhtml-modules; anonceoption covers the<style>fallback. html-modules inserts no<script>and uses noeval. 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 withintegrity, 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 throughurl()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 nativeimport(): 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 mapintegrity, which the browser enforces forimport(); anintegrityattribute on the import is checked against it (Pinning JavaScript imports). Without one, use a CSPscript-srcallowlist. integritycovers only what you give it. An attribute pins one fetch; a re-export or dependency without its ownintegrity, or an entry in the manifest, is fetched unverified unless you turn onstrict. 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, andconnect-src/script-srcdecide 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-…',
