PRD coverage and extensions
The authoritative spec is the PRD “Declarative HTML Modules” (and the conversation that produced it).
This document maps each PRD section onto the code as it stood at the start of the refocus
(commit 2e04caa, the rename), and says what to keep, change, remove or add.
Where the code started
Section titled “Where the code started”The library had grown out of a different conversation, about routing JavaScript imports across CDNs (which became the separate mport library). Its source was about 1,900 lines:
| Area | Files | What it did |
|---|---|---|
| HTML module format | html-module.js, namespace.js |
Any element with an export="…" attribute was an export: <template export> → HTMLTemplateElement, <style export> → CSSStyleSheet, <script type=module export> → a JS namespace, JSON scripts, <svg export>. Re-exports through <module-export from>. Produced a frozen, ESM-like namespace. |
| Declarative imports | declarations.js, elements.js, scope.js, interpret.js |
<module-import from> with <module-binding name as element adopt> children, default= and namespace= locals held in a per-document ModuleScope, <define-element name component> for later registration, template → shadow-stamping class, one export registered under several tags via subclasses. |
| Loader | loader.js |
Resolution through an import map, then a router, then hostResolve; a URL-keyed promise cache; onEvent; deadlock-free re-export cycle detection. |
| Package routing | routers/* (basic, import-map, mport adapter fromMport), import-map.js |
Import-map resolution and compilation, an mport-format lockfile, router chaining. |
| CLI | bin/… |
build (import map + lockfile + --html) and resolve. |
| Demo | 13 pages | Mostly routing, CDN, import-map, lockfile and loader-hook pages. |
There was no namespaced registration, no component-definition object, no compiler and no JS-authored component protocol.
Section by section
Section titled “Section by section”| PRD section | Status before | Decision |
|---|---|---|
| §1 Executive summary: HTML format, declarative import, namespacing, JS runtime, compiler, shared definition, JS interop | Only the first two, under different names | Change / add everything below. |
| §3 Design philosophy: loading → parsing → export discovery → namespace binding → definition → registration → instantiation as separate stages | Loading and binding existed; definition and registration were fused in interpret.js |
Change: separate record (parse output), definition (defineHTMLComponent), binding (bindModule) and registration (definition.define(tag)). |
| §4 Goals | Partly | See rows below. |
| §5 Non-goals: no custom JS loader, no service worker, no bundler, import maps not responsible for HTML | Violated in spirit: routers, CDN routing, import-map compilation and lockfiles were core features | Remove src/routers/*, fromMport, compileImportMap, lockfiles, the mport peer and dev dependency, the CLI build. Resolution becomes standard: relative to the importing document or module, bare specifiers through the page’s own import map (import.meta.resolve) via a hostResolve hook. mport stays a separate library for package/CDN routing. |
§6 No direct import "./ui.html" from JS |
Held | Keep. JS gets HTMLModules.load() and the compiler instead. |
| §7 Core concepts: module, export, import, component definition, import binding (include later) | Module, export, import only | Add the component definition and the binding as first-class objects. HTML Include is deferred (see below). |
| §8 HTML module = ordinary HTML with one or more exports | Held (any HTML) | Keep. |
§9 Export syntax <html-export name="…"><template>…</template></html-export>, preferred over <template export> and data-*; metadata such as shadow only with concrete semantics |
Used export="…" attributes on built-in elements, which §9.1 explicitly rejects |
Change to <html-export name>. Implemented metadata with concrete semantics only: shadow="open|closed", delegates-focus, default. Default exports are spelled name="default" (see the extension below). <style> children of an export become the definition’s styles (one constructed sheet shared by every instance). |
§10 Templates as the V1 payload, native <slot> |
Held (template stamped into an open shadow root) | Keep, moved into the shared runtime. |
§11 <html-import src as> = import * as ui; as is a namespace, not a rename |
<module-import from namespace=> bound a JS-style local, not elements |
Change: <html-import src="./ui.html" as="ui"> registers every component export as ui--<export>. |
§12 -- namespace delimiter |
Absent | Add. Namespaces and export names are lower-case kebab words, so neither can contain -- and every tag splits unambiguously. Note: §12 says . is not permitted in custom element names; it is (ui.custom-card registers in current browsers, as the namespaces example shows live). The real problem with . is one-word exports: ui.card has no hyphen. -- is still the right choice, and the docs give that reason instead. -- stays the default; making it configurable is an extension (below). |
| §13 Identity vs registration name | Partly (element= chose the tag, but a template had no identity of its own) |
Add: a definition carries its module-local name; the importer chooses the tag. The same definition can be bound as ui--custom-card and admin--custom-card. |
| §14 One constructor cannot be registered twice; use generated subclasses | Held (renamedSubclass) |
Keep, moved into definition.define(): every registration is a fresh subclass of the definition’s base element. |
| §15 Runtime loading: resolve, fetch, DOMParser, discover exports, create definitions, bind, register | Held, apart from definitions and namespaces | Change the pipeline to produce definitions. |
§16 Module cache: Map<ResolvedURL, Promise<HTMLModule>> |
Held | Keep (failed loads are evicted so they can be retried), keyed by kind and URL (html:<url>), with HTMLModules.unload(src) to evict on purpose. |
| §17 Asynchronous upgrade: elements may appear before their import finishes; native upgrade, no MutationObserver | Implicitly held | Keep and test explicitly, including customElements.whenDefined and :not(:defined). |
§18 HTML Component Definition { name, template, shadow, styles } shared by runtime and compiler |
Absent | Add defineHTMLComponent() in src/runtime.js, the only place component semantics live. |
| §19 Shared runtime architecture: browser loader and compiler both feed the same runtime | Absent | Add: both paths produce the same JSON-able module record (readHTMLModule from a DOM, scanHTMLModule from source text) and the same defineHTMLComponent() calls. |
§20 Programmatic API HTMLModules.load(), sharing the cache and parser with <html-import> |
createLoader().load() existed |
Change: HTMLModules.load / import / bind / resolve / cache; <html-import> is a thin layer over the same instance. |
§21 JS-authored components; a components manifest; never assume every JS export is a component |
Any class could be bound, but there was no manifest protocol | Add: <html-import src="./ui.js" as="ui"> registers the module’s components manifest, or failing that its exports made with defineHTMLComponent(). Other exports (VERSION, formatDate) are never registered. JS classes can also be wrapped with defineHTMLComponent(Class), and can extend an HTML definition’s .element. |
Compiler (conversation §3–§8, PRD §1, §6): html-module ui.html -o ui.js; exports are definitions and do not self-register; .define(name); --format register as sugar; compileHTMLModule(source) in memory |
Absent (the CLI compiled import maps instead) | Add compileHTMLModule() and the html-module CLI with esm (default) and register formats. The output imports only the runtime, exports one definition per component, a components manifest (so <html-import src="./ui.js"> works on it too) and a default export when there is one. |
Old capabilities: kept, renamed or removed
Section titled “Old capabilities: kept, renamed or removed”| Old capability | Decision |
|---|---|
<module-import> / <module-binding> |
Renamed to <html-import> / <html-binding> (see the extension below). |
<define-element name component> |
Removed. Its job (choosing a tag for one export) is <html-binding export element>, or definition.define(tag) in JS. |
<module-export from> re-exports (barrels) |
Kept as <html-export src="…"> (export *), <html-export src="…" name="x" import="y"> (one export, optionally renamed), and extended to every ESM form: names="a, b as c" (a list), import="*" (export * as ns), and name="default" (a default re-export). Sources may be HTML or JS modules. Cycle detection kept. |
<style export> + adopt |
Kept as a stylesheet export, <html-export name="theme"><style>…</style></html-export>, adopted with <html-binding export="theme" adopt> into the import’s root (the document, or the shadow root that contains the import). |
<script type="application/json" export> |
Kept as a data export, <html-export name="config"><script type="application/json">…</script></html-export>. |
<script type="module" export> inline scripts, <svg export>, arbitrary-element exports |
Removed. Behaviour comes from JS modules (§21), not scripts embedded in HTML modules; the conversation defers hybrid script semantics past V1. |
ModuleScope, default= / namespace= locals |
Removed. HTML imports bind element names, not JS-style identifiers; values are exposed on the import element (el.module, el.bindings) and through HTMLModules.load(). |
Loader cache, events, fetch / parseHTML hooks |
Kept for tests and SSR, with events renamed to the new pipeline (fetch, load, error). |
| Import-map resolver, compiler, merge, script, routers, mport adapter, lockfile, CLI build/resolve | Removed. |
Deliberate extension beyond the PRD: <html-binding>
Section titled “Deliberate extension beyond the PRD: <html-binding>”The PRD specifies only the whole-namespace form, <html-import src as>. The user asked to keep the
capabilities of the old <module-binding> under the spec’s vocabulary, as optional children of the
one import element:
<html-import src="./ui.html" as="ui"> <html-binding export="card"></html-binding> <!-- only this one: ui--card --> <html-binding export="button" element="brand-button"></html-binding> <!-- an explicit tag --> <html-binding export="theme" adopt></html-binding> <!-- a stylesheet, adopted --></html-import><html-import src="./counter.js"> <html-binding export="Counter" element="x-counter"></html-binding></html-import>Rationale:
- Registry pollution. Custom-element names are global and permanent. Importing a large library as a namespace registers every export forever; selective bindings register only what the page uses.
- Identity vs registration name (§13), declaratively.
element=is the markup form ofdefinition.define(tag), which the PRD already specifies for JS. - Non-element exports. Stylesheets and data need a way to be used from markup (
adopt,el.bindings) without being mistaken for components. - JS interop (§21). Binding a named class from a plain JS module needs no manifest.
Rules: with no <html-binding> children, every component export is registered as <as>--<export>
(the PRD’s behaviour). With any children, only those exports are bound. element= overrides the tag;
the same export may be bound to several tags (a subclass per tag). export="default" binds a default
export (it needs element=). An import with no as and no bindings still loads the module, for its
side effects and to warm the cache. Bindings added later, or before the module has loaded, are
applied when it is ready; elements already in the page upgrade natively. Failures (a missing export,
an invalid tag, a non-element bound to element=) fire an error event on the binding, which bubbles
through the import.
Extension beyond the PRD: configurable namespace delimiter
Section titled “Extension beyond the PRD: configurable namespace delimiter”The PRD (§12) fixes the delimiter at --. It stays the default everywhere, and can now be changed:
- per import:
<html-import src="./ui.html" as="ui" delimiter="-">registers<ui-custom-card>; - per call:
HTMLModules.import(src, { as, delimiter }),HTMLModules.bind(ns, { as, delimiter }),bindModule(ns, { as, delimiter }),registerComponents(ns, { as, delimiter }); - per instance:
createHTMLModules({ delimiter })(the attribute and the options override it); - in the compiler’s register format:
compileHTMLModule(source, { format: 'register', as, delimiter }),html-module --format register --as ui --delimiter -.
Rules. A delimiter is non-empty and made of characters allowed in custom element names (no upper case or
whitespace). Each resulting tag must be a valid custom element name; delimiter="." with a one-word export gives
ui.card, which has no hyphen, and fails with a SyntaxError naming the tag and the reason, through the usual
pathway (error events, rejections; a namespace import checks all its tags before registering any). With -,
tags can be ambiguous (ui-custom-card), so nothing parses tags: bindings record { tag, namespace, export }
(el.tags, and tags in bind() / import() results). parseBindingName(tag, delimiter) survives only as a
display helper that returns null for a tag that does not split exactly one way. Module records carry an
import’s delimiter when it is written, so the DOM reader and the scanner agree; inside modules an import without
one uses --, never the page’s instance default, so a module’s templates always match the tags they get.
Extension beyond the PRD: settings elements and lazy loading
Section titled “Extension beyond the PRD: settings elements and lazy loading”The PRD fixes the delimiter at -- (§12) and has no settings element: every import is configured on its own
element, and every module loads when its import connects. Two elements now set defaults for one document, and
imports can load lazily.
| Addition | What it does |
|---|---|
<html-import-settings delimiter base conflict load errors> |
defaults for the <html-import> elements of the document it is in: a page, or (read into the module record by both the DOM reader and the scanner) an HTML module |
<html-module-settings shadow delegates-focus> |
defaults for the component exports of the module it is in; baked into each component record, so runtime and compiled definitions agree |
conflict="reuse" |
keep a tag’s existing, different definition instead of failing; recorded as reused: true in el.tags |
errors="throw" |
also reportError() binding and loading failures, for development |
base |
resolve a document’s import (and re-export) specifiers against a folder, relative to the document’s (or module’s) own URL; the compiler rebases dependency specifiers the same way |
load="lazy" |
fetch a module only when one of its tags is used, watching the document and html-modules component shadow roots; el.load(), el.state, module-level lazy imports, HTMLModules.import(src, { load: 'lazy' }) handles |
Rules, in brief (Settings and lazy loading has them in full):
- Scope is lexical. A page’s settings apply to that page’s
<html-import>elements, a module’s to that module’s own imports and exports. Page settings andcreateHTMLModules()options never reach modules: as with the delimiter rule above, a module’s templates must keep matching the tags its author wrote. - Placement. Before any
<html-import>(or<html-export>), at most one per document. Late or duplicate settings are errors; in a page they are ignored and never retroactively change started imports; in a module they areSyntaxErrors. - Precedence. Per-import attribute > the document’s
<html-import-settings>>createHTMLModules()options (pages only) > built-in defaults.baseis document-level only. - Validation. Unknown attributes (other than
id,class,data-*) and bad values are errors that list the valid values. Invalid page settings fail that page’s imports, rather than letting them run with other options. <html-module-settings>in a page is an error event, not a silent no-op: a page has no exports.- Lazy loading is a runtime concern. Compiled dependencies are static imports, so compiled registration is
eager;
loadis carried in$importsfor fidelity only. What lazy loading does not observe (shadow roots made by other code, iframes, template contents) is documented;el.load()covers those cases.
Default export spellings
Section titled “Default export spellings”The PRD does not specify how a default export is written. The first implementation used a boolean default
attribute (<html-export default>, with name optional). That changed to mirror JS:
| Markup | Meaning |
|---|---|
<html-export name="default"> |
the default export (canonical), export default |
<html-export name> (empty name) |
the same; beware templating that renders name="" |
<html-export name="card" default> |
card and also the default, export { card, card as default } |
<html-export default> |
error: points to name="default" |
no name and no src |
error mentioning name="default" |
two defaults (name="default" twice, or with name="x" default) |
error |
name="default" default, default="yes" |
error |
a star re-export (src, no name) with default |
error: star re-exports are never the default |
a re-export with name="default" (or empty), import="x", or name="x" default |
the default re-export: export { default } from, export { x as default } from, export { x, x as default } from |
default and components remain reserved as named exports. The default is never star re-exported, a
default-only component is not in the components manifest and is not registered by as= (the importer names it
with <html-binding export="default" element="…">), and components, stylesheets (adopt) and data can all be
bound as the default. The compiler emits export default ….
Extension beyond the PRD: hardening and lifecycle
Section titled “Extension beyond the PRD: hardening and lifecycle”Added after an audit of the first build; none of it changes the PRD’s model, and each item has its page in the API reference.
| Addition | Why |
|---|---|
integrity (SRI, SubtleCrypto), credentials, mode; the Security model page |
A module URL is as trusted as a <script src>: pinning and fetch options are the available levers. JavaScript modules cannot be verified by import() and are refused rather than trusted. |
sanitize (a template sanitizer hook), @johnhenry/html-modules/safe-fragment |
The residual risk of the row above, for modules from less-trusted origins: opt-in, fail-closed, described below. |
trustedTypes policy, nonce, configureRuntime() |
template.innerHTML and the loader’s parse of a fetched module throw under require-trusted-types-for 'script'; the <style> fallback needs a nonce under a strict style-src. |
Relative url()s in a module’s CSS rewritten to absolute against the module; @import is a SyntaxError |
url() in a module’s CSS belongs to the module. Browsers ignore CSSStyleSheet’s baseURL, so the CSS text is rewritten. replaceSync() silently drops @import. |
| A lazy import with nothing to wait for is an error | It could never load. (Before, el.load() was the only way; HTMLModules.load() does that now.) |
<html-import> properties, deferred start, src change error |
A scripted createElement / append / setAttribute('src') used to end in an error with no fetch. load is the method, so the attribute’s property is loadMode. |
A misplaced <html-binding> is an error |
A self-closed <html-binding /> nested the next binding, which was dropped silently. |
renderDeclarative(), and keeping a server-rendered root |
Declarative shadow DOM is how a server renders a component; the runtime kept the root but skipped its styles, and could not see a closed one. |
unadoptStylesheet(), HTMLModules.unload(), type / integrity on <html-export src> |
Counterparts: what can be adopted can be un-adopted, what can be loaded can be evicted, and a re-export can be typed like an import. Custom elements remain impossible to undefine. |
Extension beyond the PRD: data binding
Section titled “Extension beyond the PRD: data binding”The PRD’s templates are static. {{attribute}} in a template’s text and attribute values, and props="name count:number"
on an export, are an extension designed to stay declarative: a binding is only an attribute name (no expressions,
no eval), text is never parsed as markup, URL attributes refuse script URLs, on* / style / srcdoc are never
bound, and an update patches only the bound nodes. No loops, conditionals or two-way binding, deliberately: they
would need an expression language. See HTML syntax.
Extension beyond the PRD: form-associated components
Section titled “Extension beyond the PRD: form-associated components”form-associated (and form-control="selector") on an export: the registered class is static formAssociated with
ElementInternals, supplying a form value (a control in the template, or el.value), validity, disabled, reset and
restore. attachInternals() is memoized so the closed-declarative-root lookup and subclasses share the one call the
platform allows. Enter in a form-control <input> does implicit submission (the default button, a disabled one blocking it),
and form-role="submit"|"reset" makes a component a submit or reset button: the platform has no custom-element submit
button (requestSubmit(el) throws), so the library fires submit with submitter itself. See HTML syntax.
Extension beyond the PRD: dev server, hot reload and Vite
Section titled “Extension beyond the PRD: dev server, hot reload and Vite”html-module dev [dir] serves and watches a directory and hot reloads open pages; HTMLModules.hotReload() swaps a
re-fetched module’s components and styles under live elements (registered classes delegate to a swappable definition,
because custom element definitions cannot be replaced); @johnhenry/html-modules/vite compiles HTML modules imported
from JavaScript and gives them the same HMR. These are development tools: the dev server is not a bundler or a custom
module loader (the PRD’s non-goals), only a static server with a change feed. See
Dev server, hot reload and Vite.
Extension beyond the PRD: scoped custom element registries
Section titled “Extension beyond the PRD: scoped custom element registries”registry="scoped" on a module’s import (or <html-import-settings>): its tags are registered in a per-component-definition
CustomElementRegistry that the component’s shadow roots are created with, so two versions of a library that share inner tag
names coexist. Feature-detected (supportsScopedRegistries()), with a warned fallback to the global registry; page-level
use is rejected because a page’s tags live in the document’s registry. See HTML syntax.
Extension beyond the PRD: an opt-in template sanitizer
Section titled “Extension beyond the PRD: an opt-in template sanitizer”The PRD’s trust model is “a module URL is as trusted as a <script src>”, which leaves no way to take markup-only
components from a less-trusted origin (issue #3). sanitize is a hook, not a sanitizer: a function every component
template passes through at load time (createHTMLModules({ sanitize }), import(src, { sanitize }), <html-import>.sanitize;
sync or async; returns a string, TrustedHTML or a DocumentFragment that is stamped without being parsed again). It runs
in the loader, after the record is read and before definitions exist, because the runtime’s viewOf() is synchronous and
a custom element definition cannot wait; it never sees module source (so <html-export> and <html-import> survive), a
sanitized module sanitizes what it imports and cannot import JavaScript, and a module is cached per sanitizer.
@johnhenry/html-modules/safe-fragment adapts @johnhenry/safe-fragment (an optional peer, not a dependency) and
derives a profile from safe-fragment’s component-template-v1 with ui--* custom elements; templates keep their ids
(idPolicy: "keep-in-shadow", because every template is stamped into a shadow root). Deliberately not done: a sanitize
attribute or <html-import-settings> entry (a function is not a setting; the compiler and scanner would have to agree on it),
sanitizing stylesheets (<style> is a non-goal in safe-fragment, ADR 0006; a module’s own <html-export><style> is outside
what the hook sees) or compiled output, and bundling a sanitizer. See Sanitizing templates.
Extension beyond the PRD: TypeScript declarations
Section titled “Extension beyond the PRD: TypeScript declarations”Declarations for every entry point, generated from JSDoc (tsc --declaration --allowJs --emitDeclarationOnly) and checked
by compiling a typed consumer in strict mode. See TypeScript.
Deferred
Section titled “Deferred”| Item | Why |
|---|---|
HTML Include (§7, optional sixth concept; <html-include src="./layout.html#header">) |
The PRD marks it optional and “may later be provided”; it composes DOM rather than defining components, and deserves its own design pass (slot projection, re-rendering, fragments). |
| Further export metadata (§9.3: registration behaviour, version, hydration hints, lifecycle modules) | §9.3 forbids adding metadata before it has concrete semantics. |
Compiler --format bundle (conversation §7) |
Mentioned as a possibility; a bundle needs a dependency walk over the file system and adds nothing to semantics. Dependencies compile file by file today (the CLI accepts several inputs). |
| Rewriting relative URLs in templates | A template is stamped into the page, so <img src="./x.png"> resolves against the page, not the module. A correct rewrite is an HTML-aware pass over every URL-bearing attribute (src, href, srcset, poster, <use href>, inline style), and it would break URLs meant for the page. Documented instead; a module’s <style> already resolves against the module (baseURL). |
Hybrid script semantics (a <script> inside an export that supplies the class) |
The conversation explicitly leaves it out of V1. JS behaviour attaches by extending a definition’s .element in a JS module instead. |