hono-aep-webc-factory
v0.2.0
Published
Build-once compiler for no-build sites: React/Vue/Svelte/Lit sources — or components you didn't write — become self-registering custom elements. The build lives here so the site never needs one.
Maintainers
Readme
hono-aep-webc-factory
The build lives here so your site never needs one. Point it at a
React, Vue, Svelte, or Lit source — or at a component you didn't write —
and get back a single self-registering .gen.js custom element that any
plain HTML page can use with one <script type="module">.
This package exists to widen what a no-build site can reach, not to introduce a way of writing things. It owns no tags, ships no components, and has no runtime presence on your site.
bun add -d hono-aep-webc-factory
bunx webc-factory buildConfig
webc.config.json next to your sources:
{
"outDir": "../docs/js/components",
"external": ["#stores", "nanostores"],
"entries": [
{ "src": "lit/counter.js" },
{ "src": "react/counter.tsx" },
{ "src": "svelte/counter.svelte" },
{ "src": "vue/counter.vue", "tag": "my-counter-vue" }
]
}- engine is inferred from the extension (
.svelte,.vue,.tsx/.jsx→ react,.js/.ts→ lit); set"engine"to override. - name defaults to the source basename →
<name>.gen.js. - tag is required for Vue, because that's the engine where we
generate the registration. Svelte declares its own tag via
<svelte:options customElement={{ tag, shadow: "none" }} />; Lit and React sources callcustomElements.definethemselves.
external is the one setting that matters
Anything holding shared state must be listed there. Externals are left as bare specifiers and resolved at runtime by the page's import map, so every artifact and the host page share one module instance. Bundle your store instead and you get one silent copy per artifact — components stop seeing each other's updates and nothing errors.
Wrap a component you didn't write
The reason this package is worth installing:
bun add react-select
bunx webc-factory wrap react react-select --tag x-select --buildYou get <x-select> usable from plain HTML. Attributes become props
(kebab → camel, JSON-parsed when parseable); rich values go through the
element's props property:
<x-select options='[{"value":"a","label":"A"}]'></x-select>
<script type="module">
document.querySelector("x-select").props = { onChange: (v) => console.log(v) };
</script>Headless / compound libraries
wrap inspects the export before generating. Libraries like Base UI,
Radix and Ark export a namespace of parts (Root, Track, Thumb…)
rather than one component — mounting that namespace throws. When one is
detected you get a compose scaffold instead, and you write the markup:
bunx webc-factory wrap react @base-ui-components/react/slider \
--tag x-slider --export Slider
# ℹ exports a namespace of parts (Root, Control, Track, Thumb, …) — headless.
# Emitting a compose scaffold; the markup is yours to write.That split is the point: the library owns behavior, state and
accessibility; the arrangement and the CSS stay yours. Force it either
way with --compose.
Honest limits: the component is bundled, so it must be installed
here; components that import their own CSS need that CSS included on your
page; and prop-shapes beyond primitives need the props property rather
than attributes. Vue wrapping rides on defineCustomElement, so it is
close to free; React wrapping mounts a root per element.
What it handles for you
The footguns this package exists to have already stepped on:
process.env.NODE_ENV="production"— otherwise you ship React's dev build (383KB instead of 182KB, in a real measurement).__VUE_OPTIONS_API__/__VUE_PROD_DEVTOOLS__/__VUE_PROD_HYDRATION_MISMATCH_DETAILS__— without these a Vue artifact throwsReferenceErrorat runtime, not at build time.- Svelte compiles in runes mode, so legacy Svelte-4 store syntax is a compile error rather than a silent fallback.
- Vue SFC templates are compiled to render functions — no runtime
compiler in the artifact — with
isCustomElementset so your other web components pass through untouched. - A provenance banner on line 1 of every artifact.
- Externals are matched by an EXACT filter. A catch-all resolve hook
claims every resolution in the graph, and packages that resolve through
an
exportsmap then vanish from the bundle silently — the build "succeeds" and the artifact throwsReferenceError: <minified name> is not definedat runtime. Fixed in 0.1.1; found by wrapping Base UI.
Weight is the trade
Each artifact carries its own runtime — N React artifacts means N copies of React. Measured on the reference site: lit 15KB · svelte 51KB · vue 69KB · react 182KB (minified). If you want several React components, load one shared runtime lazily instead with hono-aep-react-jit. The build prints sizes on every run so the trade stays visible.
