Skip to content

Latest commit

 

History

History
115 lines (90 loc) · 45.8 KB

File metadata and controls

115 lines (90 loc) · 45.8 KB

Tasks

Hierarchical task list for this project. Both the user and any agents read and update this file.

Conventions

  • IDs are stable. Once assigned, never reuse. Format: top-level T-001, sub-tasks T-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 ## Done when 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.

Open

  • T-024 Catalog generator: recursive directory mode + tag hook (GH issue #5) — catalogEntriesFromDirectory / buildCatalog accept recursive: true (sorted, deterministic walk; dot-dirs and node_modules skipped; CLI: -r/--recursive) and a programmatic tag(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) or null/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-in defineElement() source-scan — a regex heuristic doesn't belong in the core; with entry.source the 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 + tag through the Vite plugin's components auto-resolution — src/adapters/vite.js still calls catalogEntriesFromDirectory(componentsDir) flat with no tag hook; pass both options through the plugin's components config (likely components: 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-023 exclude option — declare tags client-only from outside the component (GH issue #2) — renderToString (and all six adapters) now accept exclude: string[] | ((tag) => boolean); matching tags are unresolved-by-choice: element left untouched (authored markup survives, upgrades client-side), no onUnresolved call, and — because the filter runs before resolution — the tag's module is never resolved or imported on the server, even when present in resolve (module-scope side effects of client-only components never run; something the issue's static ssrDisabled class 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 (exclude section, cross-linked from onUnresolved). (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 so window.* lands on the stubs), matchMedia, IntersectionObserver/ResizeObserver/MutationObserver (one inert ObserverStub), requestAnimationFrame/cancelAnimationFrame, CSSStyleSheet, localStorage/sessionStorage, navigator, location, global addEventListener/removeEventListener/dispatchEvent, and a wider document surface (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's setTimeout(cb, 0) — element-js batches update() 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 reads template() synchronously and never awaits a frame. document stays all-or-nothing (own literal only, never patches a foreign/partial document). +9 tests (new test/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 ComponentConfig resolve values — per-component DSD style injection + adoptGlobalStyles override (GH issue #4) — a Catalog value (or resolver-fn return) may now be { component, styles?, adoptGlobalStyles? }, detected by its own component key (deliberately NOT the issue's proposed constructor{}.constructor resolves 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-owned TAGNAME-SSR{index} id-space so element-js' TAGNAME{index} hydration ids/de-dup stay untouched (a naive prepend into _styles would shift client-side indices). adoptGlobalStyles overrides the instance option at render time. Chose the issue's variant 1 (declarative, internals hidden) over the prepareInstance hook (variant 2 would still leak the _styles/_options mutation contract). Lazy component loaders cache like bare ones; invalid component throws 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: transformNode wraps renderComponent in 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. New onError(tag, error) hook mirroring onUnresolved, 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: no isolateErrors flag (isolation always on; fail-fast = rethrow from your own onError) and the default reporter is console.error and NOT dev-gated (unlike the unresolved warning — a swallowed exception must surface in prod logs). +6 tests, suite green at 80; docs API reference (onError section) + 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) — renderComponent now receives the node-html-parser element it renders for and backWithNode() installs the DOM-introspection surface on the instance (children/childNodes/childElementCount/firstElementChild/lastElementChild, innerHTML/textContent, querySelector(All), getAttribute normalized to null/hasAttribute), wired before properties() 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, direct hasAttribute, childNodes/lastElementChild added). +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 ./node covering 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 (web Response) — kernel transformHtmlResponse; canonical Astro (T-002) + Nuxt/Nitro (T-010); covers every Nitro framework. (2) HTML string in/out — call renderToString directly; canonical SvelteKit (T-011). (3) Node res buffering — the ./node adapter (T-015.1); covers the whole (req,res,next) server family via docs. (4) Build-time document transform / SSG — Vite transformIndexHtml (T-015.8) + Eleventy addTransform (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: renderToString parses 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/raw http). Widest reach, near-zero cost, no streaming concern. Do first. Delivered (2026-06-10): src/adapters/node.js (./node export) buffers res.write/res.end and transforms the collected text/html body once on end (fixing Content-Length); non-HTML / already-flushed / failed-transform pass through untouched. (Note: a plain Node server gives no Response and no string hook, so it does NOT reuse the transformHtmlResponse kernel 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). Example examples/node/ — Express, SSR-only to isolate the adapter, composing element-library's ./catalog + a hand-written lazy local Catalog (no import.meta.glob in plain Node); verified via npm start + curl: 8 DSD templates, el-button (catalog) + x-greeting light-DOM + attribute-seeded x-counter (Apples: 3 / Pears: 0). Docs docs/frameworks/node.md covers Express plus mounting the same middleware on raw http/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:response Response adapter already covers it. Done 2026-06-11: row + shared Nitro snippet in docs/frameworks/index.md (the ./nuxt adapter is Nitro-generic). (was blocked by T-015.0 — no longer; we document buffer-to-done.)
    • T-015.3 Remix v3docs-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, and remix/ui/server exposes renderToString() (→ HTML string) + renderToStream(). → primary shape 2 (render to string with Remix's renderToString, run through our renderToString aliased, then createHtmlResponse); 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 in docs/frameworks/index.md with 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-seeded Apples: 3, el-button from catalog, nested button in el-notification; docs build clean). Mechanics: shape-4 framing, shape-2 mechanicsaddTransform hands 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 ./eleventy adapter rather than a doc-only 4-liner — parity with the other shape reps, and the .html/this.page.outputPath gate is the one Eleventy-specific bit worth encapsulating + testing; (b) SSR-only example (no client bundler), mirroring examples/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 no import.meta.glob → generated _catalog.js + gen:catalog/prebuild scripts, exactly like the Vite example.
      • T-015.7.1 src/adapters/eleventy.js + "./eleventy" export — elementSSR(opts) returns a non-arrow function (content) (must keep this.page) that gates on (this.page?.outputPath || "").endsWith(".html") (handles false permalinks) then return 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 faked this ({ page: { outputPath } }): asserts DSD output for .html; .json + false-outputPath passthrough; lazy resolve (loads only what's present); serializeState emits the ejs/json script. Done — green.
      • T-015.7.3 examples/eleventy/ (11ty 3.x, ESM config) — Markdown content (src/index.md) + Nunjucks layout (src/_includes/base.njk) authoring x-counter (DSD) + x-greeting (light DOM) + el-button/el-notification from element-library's ./catalog; eleventy.config.js imports the dom-shim first, registers the adapter via addTransform with resolve: [catalog, ./_catalog.js]; gen:catalog/predev/prebuild scripts; file:../.. renderer dep. SSR-only. README. Verified: static top-level import order sufficed (no rollup hoist); content-authored element in Markdown rendered (Eleventy's default html: 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.md rows (both tables) + root README framework lists (and repointed the README "Framework integrations" link to /frameworks/). Docs build green (no dead links). Done.
    • T-015.8 Plain Vite adapter + example (./vite) — a Vite plugin using the stable transformIndexHtml hook to pre-render custom elements into index.html at 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; transformIndexHtml is unaffected by it. (The Environment API matters to us only indirectly — the meta-framework adapters will migrate onto it, and the legacy ssr top-level property + server.ssrLoadModule() are slated for deprecation once it stabilizes, which would affect a Vite-SSR-via-Express recipe but not this transformIndexHtml plugin.) (ref: https://vite.dev/guide/api-environment) Delivered (2026-06-08): src/adapters/vite.js (./vite export) + examples/vite/ (MPA, authored markup in index.html, generated src/catalog.js via gen: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 components option on the existing ./vite elementSSR plugin (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 (reusing catalogEntriesFromDirectory), builds { tag: () => import(fileURL) } in memory, and merges it into resolve (own components last → win a clash). No CLI run, no committed catalog file. HMR/dev-watch: configureServer adds the dir to Vite's watcher; on add/unlink/change of a *.js inside it, the plugin rebuilds the catalog and ws.send({type:'full-reload'}) — a per-change ?v= cache-buster on the loader's import() 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 composes resolveSources()); test/vite.test.js +3 (discovery render, composes-with-resolve, watcher fires full-reload + ignores outside files). Verified: examples/vite migrated to components: "./src/components" (dropped gen:catalog/predev/prebuild + the committed src/catalog.js); npm run build → 9 DSD templates, and vite dev serves 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 what docs/frameworks/node.md already 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: new docs/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-016 Optional tree/AST-level transform API — expose a transformTree(node) entry (parse5/DOM node in, mutated in place) alongside the string renderToString*, 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 → serialize collapses 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.md into 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 to main, 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); add vitepress devDep + docs:dev/build/preview scripts; gitignore dist/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.md to overview + install + quickstart + docs link
    • T-012.5 GitHub Actions deploy-docs.yml (build + deploy to Pages on push to main)
  • T-001 Fix SpreadAttributesDirective.stringify() in @webtides/element-js to skip undefined/null/NaN like its update() does (SSR currently leaks name='undefined')

    • T-001.1 Mirror the update() guard in stringify() (directives.js)
    • T-001.2 Add a test covering the SSR omit-attribute case
  • T-003 Let consumers use @webtides/element-library as an SSR source without hand-building a registry. Done 2026-06-10 — renderer-side resolution delivered (T-013 generator, T-018 single-Catalog surface); element-library ships ./catalog in 0.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 "ship all.server.js in element-library": instead of a library-side eager { tag: Class } map (wrong axis — .server conflates server/client with value/side-effect, and "all" forces eager loading of every component, fighting the lazy design), the renderer turns any custom-elements.json into a lazy importer map. Re-reframed by T-017: this is now build-time (element-ssr gen --manifest → a static, bundler-traceable map wrapped in lazy()), not a runtime resolver — the runtime fromManifest was 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 single Catalog type + auto-detecting normalization that removes lazy()). (ref: WORKLOG#2026-06-04, WORKLOG#2026-06-05)

    • T-003.1 Add fromManifest(manifest, { base, pick }) to resolve/node.js — reads tag→module from a parsed CEM, imports each class on demand, caches per tag; Node-only (runtime-string imports), beside fromDirectory. Tests + docs. Superseded — removed in T-017 (the runtime resolvers were deleted); the manifest path is now build-time only via element-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 tag v0.2.0 → OIDC CI; verified catalog.js + exports["./catalog"] present in the published tarball). Consumers can now import 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.) Named catalog — a singular collective for "the set of elements available, and how to load each." Deliberately not registry: like CustomElementRegistry, 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 run element-js-ssr-renderer catalog --manifest custom-elements.json -o catalog.js — emits export default { "el-button": () => import("./src/components/button/button.js"), … }: a lazy Catalog (see T-018.1) of all 26 tags; (2) add "./catalog": "./catalog.js" to exports and catalog.js to the files allowlist; (3) regenerate on each release via a gen:catalog script + prepack hook 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:catalog npm script + prepack hook; smoke-test that a consumer passing the imported catalog to resolve resolves a known tag (e.g. el-button) to its class. Done 2026-06-10 (element-library feat/ssr-catalog-export commit cddffe1, unblocked by the renderer's 0.1.0 npm publish): prepack now runs analyze → build:types → gen:catalog so catalog.js regenerates on every release; test/catalog.smoke.mjs (a plain-Node check, outside the browser-mode Playwright suite) imports the renderer's dom-shim + renderToString, passes the imported catalog to resolve, and asserts el-button → Declarative Shadow DOM; exposed as test:catalog and run in both CI workflows. Added @webtides/element-js-ssr-renderer ^0.1.0 devDep. Merged to element-library main and released as 0.2.0 (tag v0.2.0); prepack shipped the generated catalog.js in 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 eager import Button/Notification blocks (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 into resolve) in the resolving-components docs. Done 2026-06-10: all four examples (astro, nuxt, sveltekit, and the T-015.8 plain-Vite one) now import catalog from "@webtides/element-library/catalog" (Astro/SvelteKit/Vite via the static import, Nuxt via dynamic import() 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 to dist/index.html at build time). Docs: new "A library can ship its own catalog" section + "Who owns which source" table in docs/resolving-components.md; examples/README.md updated. (ref: WORKLOG#2026-06-10)
    • T-003.3 Superseded by T-018 — the planned ImporterMap→ naming tweak grew into the full resolution-surface redesign (the single Catalog type + auto-detection + CLI rename). Tracked there; this ID retired to keep the trail. See T-018.1.
  • 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: resolve takes a plain Catalog and raw import.meta.glob() output directly, and lazy() is gone. Gates T-003.2 (the library can't ship a ./catalog until 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.glob returns an anonymous Record<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 from lib.dom, not invented) or Vite's exact lazy shape () => Promise<unknown> (so import.meta.glob() output is directly assignable, no cast). Retire Source / ImporterMap / ResolveFn / Registry; do NOT add Definition / Loader / Resolver as named exports — the eager class is CustomElementConstructor, the lazy value is the inline Vite thunk, and the (tag) => … function form is still accepted by resolve but described inline, not named. Typedef/JSDoc only, across render-to-string.js, the generator, and docs. (supersedes T-003.3)
    • T-018.2 Auto-detecting normalization — the renderer inspects each Catalog entry instead of demanding a wrapper: (a) class vs loader via value?.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 /; an import.meta.glob key always does) → path keys map to tags by basename, and loader module results get .default picked. Net effect: a hand-written/library Catalog and raw import.meta.glob("./x/*.js") both drop straight into resolve with no wrapper. This is the change that deletes lazy() from the happy path.
    • T-018.3 Replace lazy() with glob(map, { pathToTag, pick }) — a thin, optional escape hatch for only what auto-detection can't infer (filename ≠ tag, or a non-default export). Delete lazy() 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-renderer with a catalog subcommand (replaces element-ssr gen), emitting a catalog.js (element-js-ssr-renderer catalog <dir> -o catalog.js | element-js-ssr-renderer catalog --manifest <cem> -o catalog.js); rename the programmatic ./generate surface to match (buildCatalog, entriesFromDirectory/entriesFromManifest → catalog-named, renderCatalogModule); update package.json bin/exports, the example gen:* 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 single Catalog type, the wrapper-free surface, and the glob() escape hatch; refresh README/quick-start snippets.
  • T-013 Build a static lazy-map generator so consumers never hand-write lazy({...}) on bundled/edge targets (Nuxt/Nitro, webpack, Workers), where fromDirectory/fromManifest can'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 ./generate export.
    • T-013.2 bin/element-ssr.js CLI (element-ssr gen <dir>|--manifest -o <file>); added bin + ./generate export + bin/ to files. 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.js instead of the hand-written map; gen:components + predev/prebuild scripts. Local-component render verified in isolation (full nuxt build blocked by an unrelated element-library packaging bug — see T-014).
  • T-014 (upstream, element-library) @webtides/element-library's npm tarball was missing the runtime src/utils/notification.js imports ../../utils/transitions.js, which wasn't shipped, so el-notification failed to build/import from the published package (blocked nuxt build in the example; the files allowlist or build needed to include src/utils/*.js). Distinct from the known patch-package postinstall gotcha. Fixed upstream in 0.1.2 (verified 2026-06-04): the 0.1.2 tarball ships src/utils/transitions.js + body-scroll.js; 0.1.1 had only fixed the patch-package issue and still shipped src/utils/ as types-only. Bumped both package.jsons to ^0.1.2; full nuxt build now 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:key and 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 need document.scripts/createElement/body; build the JSON directly)
    • T-007.3 Handle Store references — serialize stores as Store/<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 serializeState option on renderToString / elementSSR) that sets globalThis.elementJsConfig.serializeState; document the import-order/SSR caveats
    • T-007.5 Tests: round-trip a component with non-default state — assert ejs:key on the host, presence/shape of the ejs/json script, and that restored values match the server state (incl. a shared-Store case)
  • 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.js convention) 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).

Done

  • T-017 Simplify the component-resolution surface. Two moves: (1) deleted the runtime Node resolvers fromDirectory/fromManifest and the ./resolve/node entry point — in the bundled-JS-SSR world this package targets, runtime FS resolution is near-never the right tool (bundler users get lazy(import.meta.glob()); edge/bundled need the static element-ssr gen map; fromManifest was strictly dominated by gen --manifest), and the genuine niche didn't justify the maintained surface + its traversal/file:-URL/edge caveats. (2) Collapsed renderToString+renderToStringAsync into one async renderToString and dropped the registry option — a { tag: Class } map is just a Source, passed via resolve (Source | Source[], later-wins). End state: one await renderToString(html, { resolve, onUnresolved, serializeState }); producers down to lazy(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 Nitro render:response hook from a server plugin (server/plugins/element-ssr.js), reusing the x-counter/x-greeting components for parity with Astro. Shipped src/adapters/nuxt.js (export ./nuxt): it wraps the Nitro response's body in a web Response, runs it through the shared transformHtmlResponse kernel, and writes the transformed HTML back in place. test/nuxt.test.js mirrors test/astro.test.js over the Nitro response-object shape (registry, lazy, non-HTML, non-string body). Nitro import-order gotcha: unlike Vite's ssr.noExternal (Astro/SvelteKit), Nitro reorders top-level module eval, so a static import '…/dom-shim' is NOT guaranteed before element-js' extends HTMLElement — the plugin loads the shim + all element-js imports via ordered dynamic import() instead. Server-side lazy resolution uses a hand-written lazy({...}) map, not import.meta.glob (Nitro isn't Vite). Verified: nuxt build + node .output/server/index.mjs emits 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 the handle server hook's transformPageChunk (which hands the HTML string directly, so the ./sveltekit adapter calls renderToStringAsync and needs no Response kernel; the adapter buffers chunks and transforms the whole document on the final done chunk). Reuses the same x-counter/x-greeting local components for parity with Astro; @sveltejs/adapter-node app, ssr.noExternal for import-order. Shipped src/adapters/sveltekit.js (export ./sveltekit) + test/sveltekit.test.js. Verified: npm run build + node build emits 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 elementSSR middleware, verifying end-to-end Declarative Shadow DOM hydration (ref: WORKLOG#2026-06-03). Lives in examples/astro/: a @astrojs/node SSR app whose middleware composes element-library components (eager static registry) with the project's own components (lazy import.meta.glob), covering both the shadow (DSD) and light-DOM render paths plus nested/composed resolution. The examples/ dir is structured for more frameworks (Nuxt/SvelteKit) — see examples/README.md for 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 → load define on client) and a per-framework hook mapping, so Nuxt/SvelteKit examples can be added alongside Astro (ref: WORKLOG#2026-06-03)
  • 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 calls import() itself, only the resolvers handed to it. (ref: T-002, T-003)

    • T-008.1 Define the Source model + resolve option: 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 onUnresolved hook); 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. Added renderToStringAsync; sync renderToString (registry-only) unchanged (ref: WORKLOG#2026-06-03)
    • T-008.3 Multiple sources + precedence: resolve accepts an array of sources; later sources win ({...a, ...b} semantics) so a project can override @webtides/element-library. Implemented in composeSources; tested + documented (ref: WORKLOG#2026-06-03)
    • T-008.4 Importer ergonomics: lazy(map, { pathToTag, pick }) — map keyed by tag or module path; default pathToTag = basename, default pick = module.default; works with import.meta.glob output 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/node entry point (filesystem import(./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 onUnresolved warns once per tag, non-production only (NODE_ENV-gated, edge-safe), suppressible via a custom onUnresolved; wired into both sync and async paths (ref: WORKLOG#2026-06-03)
    • T-008.7 Wire elementSSR (astro) onto renderToStringAsync + accept resolve/onUnresolved; JSDoc shows static-registry and lazy import.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; registry noted as still supported (ref: WORKLOG#2026-06-03)
  • T-006 De-duplicate emitted <style> blocks — light-DOM component styles now emit once per TAGNAME{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' adoptGlobalStyles option — 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, respecting true | false | string | string[] ('document' token skipped) (ref: WORKLOG#2026-06-03)

  • T-004 Emit light-DOM component styles in SSR output — renderComponent computed _styles but the light-DOM branch dropped them; now inlined ahead of the markup (ref: WORKLOG#2026-06-03)