Hierarchical task list for this project. Both the user and any agents read and update this file.
- IDs are stable. Once assigned, never reuse. Format: top-level
T-001, sub-tasksT-001.1,T-001.2. Increment monotonically. - Status checkboxes:
[ ]open[~]in progress[x]done[!]blocked (note the blocker inline)
- Don't delete completed tasks — leave them checked off for history. Move them under
## Donewhen convenient; archive only if the file gets unwieldy. - One line per task, imperative mood. Sub-tasks indent by 2 spaces.
- Optional inline metadata in parentheses:
(owner: @name),(due: YYYY-MM-DD),(blocked by: T-003),(ref: WORKLOG#YYYY-MM-DD). - Sub-tasks may have their own sub-tasks (
T-001.1.1), but prefer flat structure where possible.
-
T-024 Catalog generator: recursive directory mode +
taghook (GH issue #5) —catalogEntriesFromDirectory/buildCatalogacceptrecursive: true(sorted, deterministic walk; dot-dirs andnode_modulesskipped; CLI:-r/--recursive) and a programmatictag(entry)hook overriding the basename→tag convention per file:entry = { path, relativePath (POSIX), basename, source (lazy read) }, returning a tag,string[](multi-element file),[](skip file) ornull/undefined(fall back to basename convention). Returned tags are validated (lower-case, hyphen) — invalid ones are skipped WITH a warning, never silently. Deliberate deviation from the issue (maintainer direction): no built-indefineElement()source-scan — a regex heuristic doesn't belong in the core; withentry.sourcethe scan is a 1-line userland hook (documented as the example), keeping the generator deterministic. +7 tests, suite green at 108; docs resolving-components (new section) + API CLI table. (ref: WORKLOG#2026-08-28, GH#5)- T-024.1 Thread
recursive+tagthrough the Vite plugin'scomponentsauto-resolution —src/adapters/vite.jsstill callscatalogEntriesFromDirectory(componentsDir)flat with no tag hook; pass both options through the plugin'scomponentsconfig (likelycomponents: string | { dir, recursive?, tag? }) and verify the dev watcher picks up adds/edits in nested folders too. Deferred by maintainer decision on 2026-08-28 — deliberately not part of T-024.
- T-024.1 Thread
-
T-023
excludeoption — declare tags client-only from outside the component (GH issue #2) —renderToString(and all six adapters) now acceptexclude: string[] | ((tag) => boolean); matching tags are unresolved-by-choice: element left untouched (authored markup survives, upgrades client-side), noonUnresolvedcall, and — because the filter runs before resolution — the tag's module is never resolved or imported on the server, even when present inresolve(module-scope side effects of client-only components never run; something the issue'sstatic ssrDisabledclass flag could not offer, since a flag is only readable after the import). Deliberate deviation from the issue: the decision lives outside the component (an environment concern, not a component fact), per maintainer direction; the flag idea may return as part of a larger config story later. List entries match case-insensitively; predicates get the lower-cased tag. +5 tests, suite green at 101; docs API reference (excludesection, cross-linked fromonUnresolved). (ref: WORKLOG#2026-08-28, GH#2) -
T-022 Broaden dom-shim browser-API coverage so real-world component modules import cleanly (GH issue #6) — the shim now also stubs, all
??=-guarded so real DOMs (browser, happy-dom, jsdom) and newer Node globals (navigator) always win:window(=globalThis, installed last sowindow.*lands on the stubs),matchMedia,IntersectionObserver/ResizeObserver/MutationObserver(one inert ObserverStub),requestAnimationFrame/cancelAnimationFrame,CSSStyleSheet,localStorage/sessionStorage,navigator,location, globaladdEventListener/removeEventListener/dispatchEvent, and a widerdocumentsurface (querySelector(All),getElementsByTagName,createTextNode,documentElement,head,adoptedStyleSheets, events). Covers the issue's empirical stub list from a ~50-component production integration. Deliberate deviation: rAF is a no-op, not the issue'ssetTimeout(cb, 0)— element-js batchesupdate()behind rAF, so an executing stub would run deferred updates against the shim after the response and any error there would be an uncaught exception outside T-020's isolation; SSR readstemplate()synchronously and never awaits a frame.documentstays all-or-nothing (own literal only, never patches a foreign/partialdocument). +9 tests (newtest/dom-shim.test.js, incl. render test proving rAF callbacks never fire), suite green at 96; docs installation + limitations. (ref: WORKLOG#2026-08-28, GH#6) -
T-021
ComponentConfigresolve values — per-component DSD style injection +adoptGlobalStylesoverride (GH issue #4) — a Catalog value (or resolver-fn return) may now be{ component, styles?, adoptGlobalStyles? }, detected by its owncomponentkey (deliberately NOT the issue's proposedconstructor—{}.constructorresolves through the prototype chain, making detection and a forgotten key ambiguous).styles(string | string[]) is injected ahead of the component's own styles — into the DSD template after adopted globals for shadow components, inlined (document-wide de-duped) for light-DOM ones — under a renderer-ownedTAGNAME-SSR{index}id-space so element-js'TAGNAME{index}hydration ids/de-dup stay untouched (a naive prepend into_styleswould shift client-side indices).adoptGlobalStylesoverrides the instance option at render time. Chose the issue's variant 1 (declarative, internals hidden) over theprepareInstancehook (variant 2 would still leak the_styles/_optionsmutation contract). Lazycomponentloaders cache like bare ones; invalidcomponentthrows naming the tag. The build-time per-component-CSS hook (Tailwind utility subsets, critical CSS). +7 tests, suite green at 87; docs API reference (ComponentConfig) + resolving-components section. (ref: WORKLOG#2026-08-28, GH#4) -
T-020 Per-component error isolation (GH issue #3) — a throwing constructor /
properties()/template()/serializeState()no longer takes down the whole-page transform:transformNodewrapsrenderComponentin try/catch and leaves the failing element untouched (like an unresolved tag — authored markup survives, hydrates client-side; strictly better than the issue's render-empty workaround), siblings and nested elements still render. NewonError(tag, error)hook mirroringonUnresolved, threaded through all six adapters; errors are collected per pass and reported once per distinct tag after the fixpoint converges. Deliberate deviations from the issue: noisolateErrorsflag (isolation always on; fail-fast = rethrow from your ownonError) and the default reporter isconsole.errorand NOT dev-gated (unlike the unresolved warning — a swallowed exception must surface in prod logs). +6 tests, suite green at 80; docs API reference (onErrorsection) + limitations. (ref: WORKLOG#2026-08-28, GH#3) -
T-019 Back SSR instances with their parsed node so light-DOM introspection matches the browser (GH issue #1) —
renderComponentnow receives the node-html-parser element it renders for andbackWithNode()installs the DOM-introspection surface on the instance (children/childNodes/childElementCount/firstElementChild/lastElementChild,innerHTML/textContent,querySelector(All),getAttributenormalized tonull/hasAttribute), wired beforeproperties()so attribute-reading property factories behave like at browser upgrade time. Fixes templates deriving markup from authored light DOM (slider counting slides, content re-slotting, attribute reads) diverging between server and browser. Adapted from the issue's production patch (not 1:1: earlier install point, directhasAttribute,childNodes/lastElementChildadded). +5 tests, suite green at 74; docs concepts + limitations updated. (ref: WORKLOG#2026-08-28) -
T-015 Framework-coverage epic — organize coverage around integration shapes, not frameworks. The renderer is a string→string engine; an adapter is a ~10-line shim bridging a framework's transform boundary to "hand me the full HTML, take the transformed HTML back." There are only four such shapes, so coverage = one canonical adapter + example per shape, and every additional framework of the same shape is demoted to a docs matrix (a hook snippet + support row), never its own example — the pattern already validated by
./nodecovering Express/Fastify/Koa/Connect/raw-http/Hono from one adapter. Rationale (decision 2026-06-11): adapters are nearly free, but examples are the real cost — each is a full app that rots (dependency bumps, lockfile resyncs, upstream packaging bugs), so we deliberately cap them. The four shapes: (1) Response in/out (webResponse) — kerneltransformHtmlResponse; canonical Astro (T-002) + Nuxt/Nitro (T-010); covers every Nitro framework. (2) HTML string in/out — callrenderToStringdirectly; canonical SvelteKit (T-011). (3) Noderesbuffering — the./nodeadapter (T-015.1); covers the whole(req,res,next)server family via docs. (4) Build-time document transform / SSG — VitetransformIndexHtml(T-015.8) + EleventyaddTransform(T-015.7); pairs with T-013's static map. New work earns its keep only if it adds a shape (T-015.7) or is off-axis (T-015.9); same-shape frameworks become matrix rows (T-015.10). (ref: examples/README.md)- [!] T-015.0 Streaming-aware transform — PARKED indefinitely (decision 2026-06-11); build only on a concrete user/issue, not speculatively. The gain is narrow — it only preserves streaming for streaming-first frameworks (TanStack/Remix v3/Next), a property nothing else benefits from — against the highest engineering cost in the project. And it fights the engine:
renderToStringparses the whole document tree (parse5), so a "chunk-safe" transform that never splits a custom element across chunks devolves into buffering per element subtree, which for a top-level shell element = buffering the whole page anyway. Deeper, it's largely orthogonal to what we do: streaming frameworks stream their own late-resolving component output, while the authored custom-element markup we transform lives in the static shell that flushes first. If demand ever materializes, prefer the cheap honest answer — a documented "buffer-to-done" non-streaming SSR mode (mirrors SvelteKit's chunk buffering) — over a true chunk-safe transform. No longer blocks anything below: those are now docs rows, not adapters. - T-015.1 Generic Node / Connect / Express middleware (
./node) —(req, res, next)adapter covering any non-meta-framework Node server (Express/Fastify/Koa/Hono-node/rawhttp). Widest reach, near-zero cost, no streaming concern. Do first. Delivered (2026-06-10):src/adapters/node.js(./nodeexport) buffersres.write/res.endand transforms the collectedtext/htmlbody once onend(fixingContent-Length); non-HTML / already-flushed / failed-transform pass through untouched. (Note: a plain Node server gives noResponseand no string hook, so it does NOT reuse thetransformHtmlResponsekernel as originally planned — it does its own res buffering.)test/node.test.js(6: pre-render, lazy-only-present, multi-write()assembly, Content-Length, non-HTML passthrough, headers-sent passthrough). Exampleexamples/node/— Express, SSR-only to isolate the adapter, composing element-library's./catalog+ a hand-written lazy local Catalog (noimport.meta.globin plain Node); verified vianpm start+ curl: 8 DSD templates,el-button(catalog) +x-greetinglight-DOM + attribute-seededx-counter(Apples: 3 / Pears: 0). Docsdocs/frameworks/node.mdcovers Express plus mounting the same middleware on rawhttp/Connect/Fastify (@fastify/middie)/Koa (koa-connect)/Hono-node + the client-hydration options for a no-bundler server — so the full server range is documented without an example per server. Suite green at 62. (ref: WORKLOG#2026-06-10) - T-015.2 TanStack Start → Shape 1, docs-matrix row (no adapter, no example). Vite + Nitro (h3) under the hood → the Nuxt
render:responseResponse adapter already covers it. Done 2026-06-11: row + shared Nitro snippet indocs/frameworks/index.md(the./nuxtadapter is Nitro-generic). (was blocked by T-015.0 — no longer; we document buffer-to-done.) - T-015.3 Remix v3 → docs-matrix row (no example). Target ground-up v3 only, not React Router 7 (which absorbed Remix v2). Boundary pinned 2026-06-11 (provisional — v3 is beta). Remix v3 (Preact rewrite) is all Web Standards: routes are Fetch controllers returning a
Response, andremix/ui/serverexposesrenderToString()(→ HTML string) +renderToStream(). → primary shape 2 (render to string with Remix'srenderToString, run through ourrenderToStringaliased, thencreateHtmlResponse); also fits shape 1 (wrap the Response in the Astro/Nuxt kernel). Streaming path (renderToStream) defers to the parked T-015.0 concern (buffer-to-done). Documented indocs/frameworks/index.mdwith a provisional snippet + a prominent beta caveat (API names from the current Remix 3 beta docs, may change; no example app for that reason) + the Preact-passes-custom-elements-through note. Sources: api.remix.run, remix.run/blog/remix-3-beta-preview. - T-015.4 SolidStart → docs-matrix row (no example). Nitro family → Shape 1, covered by the Nuxt adapter. Done 2026-06-11: row + shared Nitro snippet in
docs/frameworks/index.md. - T-015.5 Analog (Angular) → docs-matrix row (no example). Nitro family → Shape 1, covered. Done 2026-06-11: row + shared Nitro snippet in
docs/frameworks/index.md. - [!] T-015.6 Next.js → PARKED (decision 2026-06-11); revisit only on real demand. The hardest and worst shape-fit: App Router streams RSC with no clean document-transform hook, forcing logic into
middleware.ts(edge → needs T-013's static map). When picked up, scope separately and document the middleware/edge flavor honestly. (was blocked by: T-015.0, T-013) - T-015.7 Eleventy (11ty) / SSG example — KEEP. Pre-render custom elements to DSD at build time — the flagship web-components SSG use case. Scoped 2026-06-11; delivered 2026-06-11 (suite green at 66; example built + verified — 8 DSD templates, content-authored
<x-greeting>in Markdown pre-rendered, attribute-seededApples: 3,el-buttonfrom catalog, nested button inel-notification; docs build clean). Mechanics: shape-4 framing, shape-2 mechanics —addTransformhands the final HTML string, returns a string (verified vs 11ty 3.x docs). Shape-rule note: strictly a second shape-4 example (Vite is the first), so the reframe would normally demote it to a docs row; it earns a full example on the authoring-model axis instead — Vite serves static-HTML apps, Eleventy serves content-driven sites (Markdown/Nunjucks + layouts + collections), the actual flagship audience. The docs must state this honestly so the reframe stays consistent. Decisions: (a) ship a thin./eleventyadapter rather than a doc-only 4-liner — parity with the other shape reps, and the.html/this.page.outputPathgate is the one Eleventy-specific bit worth encapsulating + testing; (b) SSR-only example (no client bundler), mirroringexamples/node— view-source/curl shows the DSD, hydration is left to the docs (no bundler in plain Eleventy → bare-specifier wall, same as Node). Pairs with the static-map generator (T-013): Eleventy is Node, not Vite, so noimport.meta.glob→ generated_catalog.js+gen:catalog/prebuildscripts, exactly like the Vite example.- T-015.7.1
src/adapters/eleventy.js+"./eleventy"export —elementSSR(opts)returns a non-arrowfunction (content)(must keepthis.page) that gates on(this.page?.outputPath || "").endsWith(".html")(handlesfalsepermalinks) thenreturn renderToString(content, opts); non-HTML passes through. Async-capable. Same options surface as the other adapters (resolve/onUnresolved/serializeState). Done. - T-015.7.2
test/eleventy.test.js(4) — invoke the returned transform with a fakedthis({ page: { outputPath } }): asserts DSD output for.html;.json+false-outputPath passthrough; lazyresolve(loads only what's present);serializeStateemits theejs/jsonscript. Done — green. - T-015.7.3
examples/eleventy/(11ty 3.x, ESM config) — Markdown content (src/index.md) + Nunjucks layout (src/_includes/base.njk) authoringx-counter(DSD) +x-greeting(light DOM) +el-button/el-notificationfrom element-library's./catalog;eleventy.config.jsimports the dom-shim first, registers the adapter viaaddTransformwithresolve: [catalog, ./_catalog.js];gen:catalog/predev/prebuildscripts;file:../..renderer dep. SSR-only. README. Verified: static top-level import order sufficed (no rollup hoist); content-authored element in Markdown rendered (Eleventy's defaulthtml: true). HTML-minifier ordering documented (none added). Done. - T-015.7.4 Docs + tables — new
docs/frameworks/eleventy.md; flipped the T-015.10 matrix row 🚧→✅ (shape 4); added the VitePress sidebar entry +examples/README.mdrows (both tables) + root README framework lists (and repointed the README "Framework integrations" link to/frameworks/). Docs build green (no dead links). Done.
- T-015.7.1
- T-015.8 Plain Vite adapter + example (
./vite) — a Vite plugin using the stabletransformIndexHtmlhook to pre-render custom elements intoindex.htmlat build/dev time, no server (same "build-time DSD, no server" bucket as T-015.7; pairs with T-013). Caveat to document in the example README: pre-rendering only does anything if the elements are authored as markup in the HTML — a JS-mounted SPA has nothing to transform, so the example must be MPA/static-HTML style. Do NOT build on Vite's Environment API — as of Vite 8 (released 2026-03-12) it's still RC/experimental and Vite explicitly recommends plugin authors not adopt it yet;transformIndexHtmlis unaffected by it. (The Environment API matters to us only indirectly — the meta-framework adapters will migrate onto it, and the legacyssrtop-level property +server.ssrLoadModule()are slated for deprecation once it stabilizes, which would affect a Vite-SSR-via-Express recipe but not thistransformIndexHtmlplugin.) (ref: https://vite.dev/guide/api-environment) Delivered (2026-06-08):src/adapters/vite.js(./viteexport) +examples/vite/(MPA, authored markup inindex.html, generatedsrc/catalog.jsviagen:catalog/predev/prebuild) +test/vite.test.js(4) +docs/frameworks/vite.md. Suite green at 62. (ref: WORKLOG#2026-06-08) - T-015.9 Vite plugin for component resolution (not rendering) — off the shapes axis (resolution, not the render boundary). Delivered 2026-06-11 as a
componentsoption on the existing./viteelementSSRplugin (Option 1 — folded in, not a separate plugin; decided 2026-06-11). Reframed the premise: the task said "auto-discover from the Vite module graph," but that source is wrong for SSR resolution — the module graph only holds modules app JS imports, so it would miss components referenced only as authored tags (never imported), which is exactly the lazy case the catalog exists for. So discovery is filesystem-sourced:components: "./src/components"→ the plugin scans the dir (reusingcatalogEntriesFromDirectory), builds{ tag: () => import(fileURL) }in memory, and merges it intoresolve(own components last → win a clash). No CLI run, no committed catalog file. HMR/dev-watch:configureServeradds the dir to Vite's watcher; on add/unlink/change of a*.jsinside it, the plugin rebuilds the catalog andws.send({type:'full-reload'})— a per-change?v=cache-buster on the loader'simport()makes Node re-evaluate the edited module (full reload, not in-place HMR, since the SSR output is regenerated wholesale).src/adapters/vite.js(configResolved/configureServer+ the render handler now composesresolveSources());test/vite.test.js+3 (discovery render, composes-with-resolve, watcher fires full-reload + ignores outside files). Verified:examples/vitemigrated tocomponents: "./src/components"(droppedgen:catalog/predev/prebuild+ the committedsrc/catalog.js);npm run build→ 9 DSD templates, andvite devserves the same 9 live. The standalone CLI generator (T-013) stays the path for non-Vite targets / committed catalogs. Suite green at 69. Complements T-013, orthogonal to T-015.8. - T-015.10 Framework support matrix (docs) — the home for every demoted same-shape framework. One table under
docs/frameworks/mapping each framework → its integration shape (1–4) → the canonical adapter export + a copy-paste hook snippet, so TanStack (T-015.2), Remix v3 (T-015.3), SolidStart (T-015.4), Analog (T-015.5) — and future Nitro/Response/string frameworks — are documented without an example dir each. Mirrors whatdocs/frameworks/node.mdalready does for the(req,res,next)server family. Fill rows as each boundary is pinned; no code, no example, no test per row. Shipped 2026-06-11: newdocs/frameworks/index.md— the four-shapes table + a full support matrix + the shared Nitro snippet (closes T-015.2/.4/.5); wired into the VitePress nav + sidebar as the Frameworks landing page; docs build green (no dead links). Eleventy added as a shape-4 ✅ row when T-015.7 landed. Done 2026-06-11: the last open row — Remix v3 (T-015.3) — is now pinned + documented (provisional, beta); matrix complete. Rows can still be refined as betas stabilize.
- [!] T-015.0 Streaming-aware transform — PARKED indefinitely (decision 2026-06-11); build only on a concrete user/issue, not speculatively. The gain is narrow — it only preserves streaming for streaming-first frameworks (TanStack/Remix v3/Next), a property nothing else benefits from — against the highest engineering cost in the project. And it fights the engine:
-
T-016 Optional tree/AST-level transform API — expose a
transformTree(node)entry (parse5/DOM node in, mutated in place) alongside the stringrenderToString*, so a host that already holds a parsed HTML tree can hand it to us directly and skip the re-parse the string path does (string → parse → transform → serializecollapses to one parse the host owns). Modest optimization, niche until a concrete integration actually hands us a tree — most framework hooks give a string. Distinct from T-015.0 (streaming, which is about not buffering the whole document, not the input representation); the two could share an internal tree-walker. -
T-012 Documentation site — present the docs with VitePress and prepare GitHub Pages hosting. Reorganize the monolithic
README.mdinto structured pages (single source of truth), add net-new API reference + Core Concepts pages, slim the README to overview/install/quickstart + a docs link, and ship a Pages deploy workflow (push tomain, no-ops until Pages is enabled in repo settings). (ref: WORKLOG#2026-06-04)- T-012.1 Scaffold VitePress under
docs/(.vitepress/config.mjs, home page, nav/sidebar,base: '/element-js-ssr-renderer/', local search); addvitepressdevDep +docs:dev/build/previewscripts; gitignoredist/cache - T-012.2 Migrate README prose into guide/concepts/resolving/frameworks/limitations pages
- T-012.3 New API reference page (renderToString/Async, lazy, fromDirectory, elementSSR, types, subpath exports)
- T-012.4 Slim
README.mdto overview + install + quickstart + docs link - T-012.5 GitHub Actions
deploy-docs.yml(build + deploy to Pages on push tomain)
- T-012.1 Scaffold VitePress under
-
T-001 Fix
SpreadAttributesDirective.stringify()in@webtides/element-jsto skipundefined/null/NaNlike itsupdate()does (SSR currently leaksname='undefined')- T-001.1 Mirror the
update()guard instringify()(directives.js) - T-001.2 Add a test covering the SSR omit-attribute case
- T-001.1 Mirror the
-
T-003 Let consumers use
@webtides/element-libraryas an SSR source without hand-building a registry. Done 2026-06-10 — renderer-side resolution delivered (T-013 generator, T-018 single-Catalogsurface); element-library ships./catalogin0.2.0(T-003.2) and the examples consume it wrapper-free (T-003.2.2). End state:import catalog from "@webtides/element-library/catalog"→resolve, no hand-built registry, no codegen. Reframed from the original "shipall.server.jsin element-library": instead of a library-side eager{ tag: Class }map (wrong axis —.serverconflates server/client with value/side-effect, and "all" forces eager loading of every component, fighting the lazy design), the renderer turns anycustom-elements.jsoninto a lazy importer map. Re-reframed by T-017: this is now build-time (element-ssr gen --manifest→ a static, bundler-traceable map wrapped inlazy()), not a runtime resolver — the runtimefromManifestwas removed. Still generalizes to any CEM-shipping package and needs no change/release in element-library. Renderer side is delivered (via T-013's generator). The canonical consumer-facing path — the library shipping its own lazy catalog so consumers write one line and never codegen — is the cross-repo T-003.2, gated on the renderer-side resolution redesign in T-018 (the singleCatalogtype + auto-detecting normalization that removeslazy()). (ref: WORKLOG#2026-06-04, WORKLOG#2026-06-05)- T-003.1 Add
fromManifest(manifest, { base, pick })toresolve/node.js— reads tag→module from a parsed CEM, imports each class on demand, caches per tag; Node-only (runtime-string imports), besidefromDirectory. Tests + docs. Superseded — removed in T-017 (the runtime resolvers were deleted); the manifest path is now build-time only viaelement-ssr gen --manifest(T-013), which is bundler-traceable and strictly more portable. - T-003.2 (element-library repo) Catalog SHIPPED in
@webtides/element-library@0.2.0(released 2026-06-10 via tagv0.2.0→ OIDC CI; verifiedcatalog.js+exports["./catalog"]present in the published tarball). Consumers can nowimport catalog from "@webtides/element-library/catalog". Both subtasks done: the library ships + releases the catalog (T-003.2.1) and this repo's examples + docs consume it (T-003.2.2). Ship the canonical element catalog —@webtides/element-library/catalog, the recommended way to consume element-library under SSR on every target (plain Node, Nitro, edge/Workers, webpack, Vite), not just a bundled/edge fallback. The key property: because the catalog ships inside the package, its specifiers are package-internal relative paths that resolve in any consumer's bundle regardless of their nodemodules layout — no hoisting/pnpm fragility, no consumer codegen, no hand-imports. (Only the package can do this: it ships alongside the source it references. The renderer can't — at runtime it has neither the bundler's module graph nor the package's public-export knowledge.) Namedcatalog— a singular collective for "the set of elements available, and how to load each." Deliberately notregistry: likeCustomElementRegistry, that word implies elements already _defined, whereas this is a list still to be defined. And not a content-bundle name like./all(a catalog is a different kind of thing — what's available, not a bundle of everything). Recipe: (1) at the package root runelement-js-ssr-renderer catalog --manifest custom-elements.json -o catalog.js— emitsexport default { "el-button": () => import("./src/components/button/button.js"), … }: a lazyCatalog(see T-018.1) of all 26 tags; (2) add"./catalog": "./catalog.js"toexportsandcatalog.jsto thefilesallowlist; (3) regenerate on each release via agen:catalogscript +prepackhook so it can't drift from the components. Consumer then writes one line, no wrapper (per T-018.2):import catalog from "@webtides/element-library/catalog"; await renderToString(html, { resolve: [catalog, import.meta.glob("./elements/*.js")] }). Cost to element-library: ~5 lines of packaging + one generator run. (ref: T-013, T-017, T-018)- T-003.2.1 (element-library repo) Wire generation into the release:
gen:catalognpm script +prepackhook; smoke-test that a consumer passing the imported catalog toresolveresolves a known tag (e.g.el-button) to its class. Done 2026-06-10 (element-libraryfeat/ssr-catalog-exportcommitcddffe1, unblocked by the renderer's0.1.0npm publish):prepacknow runsanalyze → build:types → gen:catalogsocatalog.jsregenerates on every release;test/catalog.smoke.mjs(a plain-Node check, outside the browser-mode Playwright suite) imports the renderer'sdom-shim+renderToString, passes the imported catalog toresolve, and assertsel-button→ Declarative Shadow DOM; exposed astest:catalogand run in both CI workflows. Added@webtides/element-js-ssr-renderer ^0.1.0devDep. Merged to element-librarymainand released as0.2.0(tagv0.2.0);prepackshipped the generatedcatalog.jsin the published tarball. (ref: WORKLOG#2026-06-10) - T-003.2.2 (this repo) Convert examples/{astro,nuxt,sveltekit} to
resolve: [catalog, import.meta.glob(...)], deleting the eagerimport Button/Notificationblocks (the manual block this whole thread is about). Document the "third-party library ships its own catalog" pattern + the responsibility-split table (your own components → bundler glob /element-js-ssr-renderer catalog; third-party library → it ships the catalog, you drop it intoresolve) in the resolving-components docs. Done 2026-06-10: all four examples (astro, nuxt, sveltekit, and the T-015.8 plain-Vite one) nowimport catalog from "@webtides/element-library/catalog"(Astro/SvelteKit/Vite via the staticimport, Nuxt via dynamicimport()for Nitro eval-order); element-library bumped to^0.2.0. Verified end-to-end — built + SSR-served each, checking DSD: astro 9 / sveltekit 9 / nuxt 8 / vite 9 shadowroots,el-button(catalog) +x-counter(local) both rendered (nuxt confirms rollup traces the package-internal catalog specifiers; vite pre-renders todist/index.htmlat build time). Docs: new "A library can ship its own catalog" section + "Who owns which source" table indocs/resolving-components.md;examples/README.mdupdated. (ref: WORKLOG#2026-06-10)
- T-003.2.1 (element-library repo) Wire generation into the release:
- T-003.3 Superseded by T-018 — the planned
ImporterMap→ naming tweak grew into the full resolution-surface redesign (the singleCatalogtype + auto-detection + CLI rename). Tracked there; this ID retired to keep the trail. See T-018.1.
- T-003.1 Add
-
T-018 Resolution-surface redesign — recast component resolution around a single honest type, the
Catalog, and let the renderer normalize inputs itself, so the common path needs no wrapper. Same spirit as T-017 (further collapse of the same surface); breaking, done pre-release. End state:resolvetakes a plainCatalogand rawimport.meta.glob()output directly, andlazy()is gone. Gates T-003.2 (the library can't ship a./cataloguntil the vocabulary + wrapper-free consumption land here). (ref: WORKLOG#2026-06-05)- T-018.1 Vocabulary — collapse to one named type (Vite names none of this data —
import.meta.globreturns an anonymousRecord<string, () => Promise<unknown>>; we align by not over-naming):Catalog=Record<string, CustomElementConstructor | (() => Promise<unknown>)>— a tag→component map whose values are either an eager class (CustomElementConstructor, borrowed fromlib.dom, not invented) or Vite's exact lazy shape() => Promise<unknown>(soimport.meta.glob()output is directly assignable, no cast). RetireSource/ImporterMap/ResolveFn/Registry; do NOT addDefinition/Loader/Resolveras named exports — the eager class isCustomElementConstructor, the lazy value is the inline Vite thunk, and the(tag) => …function form is still accepted byresolvebut described inline, not named. Typedef/JSDoc only, acrossrender-to-string.js, the generator, and docs. (supersedes T-003.3) - T-018.2 Auto-detecting normalization — the renderer inspects each
Catalogentry instead of demanding a wrapper: (a) class vs loader viavalue?.prototype instanceof HTMLElement(an eager class extends HTMLElement through the dom-shim; a() => import()loader is an arrow fn with no such prototype); (b) tag-key vs path-key via/presence (a custom-element tag can't contain/; animport.meta.globkey always does) → path keys map to tags by basename, and loader module results get.defaultpicked. Net effect: a hand-written/libraryCatalogand rawimport.meta.glob("./x/*.js")both drop straight intoresolvewith no wrapper. This is the change that deleteslazy()from the happy path. - T-018.3 Replace
lazy()withglob(map, { pathToTag, pick })— a thin, optional escape hatch for only what auto-detection can't infer (filename ≠ tag, or a non-defaultexport). Deletelazy()entirely (no alias; pre-release). Document it as rarely needed. - T-018.4 CLI + generator rename — the bin matches the package name:
element-js-ssr-rendererwith acatalogsubcommand (replaceselement-ssr gen), emitting acatalog.js(element-js-ssr-renderer catalog <dir> -o catalog.js|element-js-ssr-renderer catalog --manifest <cem> -o catalog.js); rename the programmatic./generatesurface to match (buildCatalog,entriesFromDirectory/entriesFromManifest→ catalog-named,renderCatalogModule); updatepackage.jsonbin/exports, the examplegen:*scripts,test/generate-lazy-map.test.js, and docs. - T-018.5 Propagate to examples + docs — astro/nuxt/sveltekit drop every wrapper (
resolve: [catalog, import.meta.glob(...)]); rewrite the resolving-components + API-reference pages around the singleCatalogtype, the wrapper-free surface, and theglob()escape hatch; refresh README/quick-start snippets.
- T-018.1 Vocabulary — collapse to one named type (Vite names none of this data —
-
T-013 Build a static lazy-map generator so consumers never hand-write
lazy({...})on bundled/edge targets (Nuxt/Nitro, webpack, Workers), wherefromDirectory/fromManifestcan't reach files at runtime. Two input modes mirroring the runtime resolvers — directory convention and CEM. (ref: WORKLOG#2026-06-04)- T-013.1
src/generate-lazy-map.js:entriesFromDirectory/entriesFromManifest/renderLazyMapModule/generateLazyMap; emits a default-exported{ tag: () => import("./rel.js") }module with specifiers relative to the output file, sorted, dup-tag guarded. Exposed as./generateexport. - T-013.2
bin/element-ssr.jsCLI (element-ssr gen <dir>|--manifest -o <file>); addedbin+./generateexport +bin/tofiles. Tests (test/generate-lazy-map.test.js, 8). Docs: resolving-components "Generate a static map" section, API-reference CLI block, installation. - T-013.3 Convert the Nuxt example to consume a generated
server/components.generated.jsinstead of the hand-written map;gen:components+predev/prebuildscripts. Local-component render verified in isolation (fullnuxt buildblocked by an unrelated element-library packaging bug — see T-014).
- T-013.1
-
T-014 (upstream, element-library)
@webtides/element-library's npm tarball was missing the runtimesrc/utils/—notification.jsimports../../utils/transitions.js, which wasn't shipped, soel-notificationfailed to build/import from the published package (blockednuxt buildin the example; thefilesallowlist or build needed to includesrc/utils/*.js). Distinct from the knownpatch-packagepostinstall gotcha. Fixed upstream in 0.1.2 (verified 2026-06-04): the 0.1.2 tarball shipssrc/utils/transitions.js+body-scroll.js; 0.1.1 had only fixed the patch-package issue and still shippedsrc/utils/as types-only. Bumped bothpackage.jsons to^0.1.2; fullnuxt buildnow succeeds and the built server SSRs all components incl.el-notification(9 DSD templates). (ref: WORKLOG#2026-06-04) -
T-007 Implement server→client state transport (element-js
serializeState/ejs:key) so stateful components hydrate with their server-rendered state instead of re-deriving from property defaults (ref: WORKLOG#2026-06-04)- T-007.1 Assign each rendered component a stable, deterministic
ejs:keyand stamp it on the host element — must match between server output and client hydration, so it can't use element-js'randomUUID()(derive from tag + document position/order) - T-007.2 Collect each component's
serializeState()output into one merged state map and emit it as a single<script type="ejs/json">…</script>in the body — the exact location/format element-js reads on the client (avoid element-js' DOM-based helpers, which needdocument.scripts/createElement/body; build the JSON directly) - T-007.3 Handle
Storereferences — serialize stores asStore/<uuid>with their state under that uuid, and de-duplicate stores shared across components, mirroring element-js' replacer/reviver (SerializeStateHelper.js) - T-007.4 Add an opt-in surface (e.g. a
serializeStateoption onrenderToString/elementSSR) that setsglobalThis.elementJsConfig.serializeState; document the import-order/SSR caveats - T-007.5 Tests: round-trip a component with non-default state — assert
ejs:keyon the host, presence/shape of theejs/jsonscript, and that restored values match the server state (incl. a shared-Storecase)
- T-007.1 Assign each rendered component a stable, deterministic
-
T-009 Optional async per-component property provider — let consumers supply server-fetched / async props for a component before its SSR render, merged ahead of HTML attributes over element defaults. The renderer currently derives props only from attributes + defaults; add a hook (e.g. a
properties(tag, node)option, or a<tag>.properties.jsconvention) for data-backed components. Salvaged from the old Magnolia renderer's<tag>.properties.js+ CMS-content pattern; distinct from T-007 (which transports already-rendered state to the client, not server-side prop seeding).
-
T-017 Simplify the component-resolution surface. Two moves: (1) deleted the runtime Node resolvers
fromDirectory/fromManifestand the./resolve/nodeentry point — in the bundled-JS-SSR world this package targets, runtime FS resolution is near-never the right tool (bundler users getlazy(import.meta.glob()); edge/bundled need the staticelement-ssr genmap;fromManifestwas strictly dominated bygen --manifest), and the genuine niche didn't justify the maintained surface + its traversal/file:-URL/edge caveats. (2) CollapsedrenderToString+renderToStringAsyncinto one asyncrenderToStringand dropped theregistryoption — a{ tag: Class }map is just aSource, passed viaresolve(Source | Source[], later-wins). End state: oneawait renderToString(html, { resolve, onUnresolved, serializeState }); producers down tolazy(import.meta.glob())+lazy(generatedMap). Updated all adapters/examples/tests/docs. Breaking; done while pre-release. Suite green at 51. (ref: WORKLOG#2026-06-04) -
T-010 Add a Nuxt example under
examples/nuxt/— integrated via a Nitrorender:responsehook from a server plugin (server/plugins/element-ssr.js), reusing thex-counter/x-greetingcomponents for parity with Astro. Shippedsrc/adapters/nuxt.js(export./nuxt): it wraps the Nitro response'sbodyin a webResponse, runs it through the sharedtransformHtmlResponsekernel, and writes the transformed HTML back in place.test/nuxt.test.jsmirrorstest/astro.test.jsover the Nitro response-object shape (registry, lazy, non-HTML, non-string body). Nitro import-order gotcha: unlike Vite'sssr.noExternal(Astro/SvelteKit), Nitro reorders top-level module eval, so a staticimport '…/dom-shim'is NOT guaranteed before element-js'extends HTMLElement— the plugin loads the shim + all element-js imports via ordered dynamicimport()instead. Server-side lazy resolution uses a hand-writtenlazy({...})map, notimport.meta.glob(Nitro isn't Vite). Verified:nuxt build+node .output/server/index.mjsemits 8 DSD templates, light-DOM greeting in place, global styles adopted, attribute-seeded props (Apples: 3/Pears: 0). Docs/README/examples tables updated. (ref: WORKLOG#2026-06-04, T-002.1, T-008) -
T-011 Add a SvelteKit example under
examples/sveltekit/— integrated via thehandleserver hook'stransformPageChunk(which hands the HTML string directly, so the./sveltekitadapter callsrenderToStringAsyncand needs no Response kernel; the adapter buffers chunks and transforms the whole document on the finaldonechunk). Reuses the samex-counter/x-greetinglocal components for parity with Astro;@sveltejs/adapter-nodeapp,ssr.noExternalfor import-order. Shippedsrc/adapters/sveltekit.js(export./sveltekit) +test/sveltekit.test.js. Verified:npm run build+node buildemits 9 DSD templates with adopted global styles. (ref: WORKLOG#2026-06-03, T-002.1, T-008) -
T-002 Stand up a runnable Astro example wired to the
elementSSRmiddleware, verifying end-to-end Declarative Shadow DOM hydration (ref: WORKLOG#2026-06-03). Lives inexamples/astro/: a@astrojs/nodeSSR app whose middleware composes element-library components (eager staticregistry) with the project's own components (lazyimport.meta.glob), covering both the shadow (DSD) and light-DOM render paths plus nested/composed resolution. Theexamples/dir is structured for more frameworks (Nuxt/SvelteKit) — seeexamples/README.mdfor the shared pattern (T-002.1).- T-002.1 Reorganize into
examples/<framework>/with a top-level index documenting the framework-agnostic integration pattern (shim-first → wrap HTML response → loaddefineon client) and a per-framework hook mapping, so Nuxt/SvelteKit examples can be added alongside Astro (ref: WORKLOG#2026-06-03)
- T-002.1 Reorganize into
-
T-008 Pluggable component resolution — consumers can supply lazily-loaded, multi-source component sources instead of (or alongside) the static
registry, so unused components never load (cold-start / serverless / edge) and projects stop hand-maintaining a registry. Core stays bundler/runtime-agnostic: it never callsimport()itself, only the resolvers handed to it. (ref: T-002, T-003)- T-008.1 Define the
Sourcemodel +resolveoption: static registry (sync), importer map (lazy), and bare(tag) => …resolver; normalize each to a uniform(tag) => Class | Promise<Class> | undefined(ref: WORKLOG#2026-06-03) - T-008.2 Async render via a resolve→render fixpoint over the existing sync transform: each pass renders with the registry resolved so far and reports unresolved tags (new
onUnresolvedhook); the wrapper resolves those in parallel (each module once) and re-runs until stable. Also catches custom elements nested in generated templates, not just the input. AddedrenderToStringAsync; syncrenderToString(registry-only) unchanged (ref: WORKLOG#2026-06-03) - T-008.3 Multiple sources + precedence:
resolveaccepts an array of sources; later sources win ({...a, ...b}semantics) so a project can override@webtides/element-library. Implemented incomposeSources; tested + documented (ref: WORKLOG#2026-06-03) - T-008.4 Importer ergonomics:
lazy(map, { pathToTag, pick })— map keyed by tag or module path; defaultpathToTag= basename, defaultpick=module.default; works withimport.meta.globoutput and hand-written maps, no bundler required. Default + override paths tested (ref: WORKLOG#2026-06-03) - T-008.5 Opt-in Node-only convention resolver behind a separate
…/resolve/nodeentry point (filesystemimport(./components/${tag}.js)), so its runtime-string import never lands in an edge bundle; documented as Node-server-only.fromDirectory(dir, { tagToPath, pick })→ResolveFn; accepts path /file:URL base, per-tag import cache, path-traversal guard, propagates module errors but treats missing files as a pass-through miss (ref: WORKLOG#2026-06-03) - T-008.6 Dev-mode warning when a hyphenated (custom-element-looking) tag resolves to nothing — catches the "forgot to add the source / typo'd the tag" case the static registry silently swallows. Default
onUnresolvedwarns once per tag, non-production only (NODE_ENV-gated, edge-safe), suppressible via a customonUnresolved; wired into both sync and async paths (ref: WORKLOG#2026-06-03) - T-008.7 Wire
elementSSR(astro) ontorenderToStringAsync+ acceptresolve/onUnresolved; JSDoc shows static-registry and lazyimport.meta.glob+ library composition. (The runnable example that exercises this end-to-end is T-002.) (ref: WORKLOG#2026-06-03) - T-008.8 Docs: README "Loading & resolving components" section (three source kinds, multi-source precedence, environment matrix, cold-start rationale, sync-vs-async, unresolved-tag warning); JSDoc on the new typedefs/options;
registrynoted as still supported (ref: WORKLOG#2026-06-03)
- T-008.1 Define the
-
T-006 De-duplicate emitted
<style>blocks — light-DOM component styles now emit once perTAGNAME{index}id across the document (id'd like element-js so the client de-dupes on hydration); adopted global styles are de-duped within each shadow root. Cross-instance shadow duplication is inherent to shadow isolation and left as-is. (ref: WORKLOG#2026-06-03) -
T-005 Honor element-js'
adoptGlobalStylesoption — collect the input document's global<style>/<link rel="stylesheet">(anywhere, excluding template-scoped) and inline matching ones into each shadow template ahead of the component's own styles, respectingtrue | false | string | string[]('document'token skipped) (ref: WORKLOG#2026-06-03) -
T-004 Emit light-DOM component styles in SSR output —
renderComponentcomputed_stylesbut the light-DOM branch dropped them; now inlined ahead of the markup (ref: WORKLOG#2026-06-03)