Vite plugin
jq79/vite lets you import .html single-file components as modules, so they
travel inside your bundle instead of being fetched at runtime.
// vite.config.js
import { defineConfig } from "vite"
import { jq79 } from "jq79/vite"
export default defineConfig({
plugins: [jq79()],
})
// app code
import UserCard from "./UserCard.html"
UserCard.mount("#app", { userId: 42 })
The imported value is a Component79 built from the file's source — exactly
what await Component79.fetch("/UserCard.html") would resolve to, minus the
network request.
A loader, not a compiler
The plugin inlines the file's source verbatim; nothing inside the component is
transformed — with the single exception of <style lang> below. The same
.html file works unchanged in all three delivery modes:
- Bundled — placed in
src/, imported as a module (this plugin). - Fetched — placed in
public/, loaded withComponent79.fetch(url)orimport("./card.html")from a setup script, no build required. - No project at all — served from any static host and used with the CDN
build of jq79.
npx jq79 devserves and hot-reloads those files without a bundler in sight.
<style lang> — CSS preprocessors
A style block with a lang is compiled to plain CSS by the plugin, through
Vite's own preprocessing. Install the preprocessor you use (sass, less,
stylus) and write what you'd write in any Vite project — nesting, @use,
variables, partials:
<style lang="scss" scoped>
@use "./vars" as vars;
.card {
color: vars.$brand;
.title { font-weight: bold; }
}
</style>
The runtime only ever sees the compiled CSS, so lang composes with
scoped — selectors are scoped after the preprocessor
has flattened them. Files pulled in by @use/@import are registered as watch
dependencies, so editing a partial triggers HMR in every component using it.
lang is the one thing that ties a component to the bundler. A .html
that never goes through the plugin — one in public/ loaded with
Component79.fetch(), one served from a CDN, one written inline as a template
string — reaches the runtime uncompiled, and a browser silently drops a
stylesheet it can't parse. So the runtime doesn't stay quiet: parsing a
component whose <style> still carries a lang logs a warning saying the
plugin never compiled it. If a component must work in both delivery modes,
write plain CSS.
Using an imported component
The import is a component definition as much as an instance:
import UserCard from "./UserCard.html"
// mount it directly (one live render per instance)
UserCard.mount("#app")
// use it as a nested component: each usage site gets its own instance,
// cloned from the shared parsed definition
new Component79(`
<ul>
<li :each="user of users">
<UserCard :user></UserCard>
</li>
</ul>
`).mount("#list", { UserCard, users })
// need several independent directly-mounted copies? clone the definition
const another = new Component79(UserCard)
Note that ES modules are cached: every import of the same file yields the
same instance. Mounting that one instance in two places moves it — clone
with new Component79(imported) when you want independent copies.
Which imports are claimed
Only imports that could not mean anything else:
- The specifier must match
include(default: anything ending in.html). - There must be an importer — entry points like
index.htmlare untouched. - Imports with an explicit query keep their built-in Vite meaning
(
./card.html?raw,./card.html?url).
Options
jq79({
// which import specifiers are treated as components (default: /\.html$/)
include: /\.c79\.html$/,
// resolved absolute paths to skip even when include matches
exclude: /\/email-templates\//,
})
Imports inside component scripts
Imports with a literal specifier — dynamic import(...) in setup scripts
and static import statements in factory scripts —
are hoisted into real module imports and bundled along with the component:
<script :setup>
const UserCard = await import("./UserCard.html") // bundled component
const { format } = await import("date-fns") // bundled npm package
</script>
<!-- or, factory style -->
<script>
import UserCard from "./UserCard.html"
import { format } from "date-fns"
export default () => ({ UserCard, today: format(new Date(), "PPP") })
</script>
The setup script itself is untouched (the plugin stays a loader): the hoisted
modules are handed to Component79 as a resolution map, which the runtime
checks before falling back to its normal behavior. The same file therefore
still works unbundled — the map simply isn't there, and the imports fetch at
runtime as always.
Left to runtime resolution on purpose:
- Absolute paths and full URLs (
import("/cards/promo.html"),import("https://esm.sh/x")) — they point at served files, e.g.public/. - Dynamic specifiers (
import(`./cards/${name}.html`)) — not statically analyzable; keep runtime-loaded components inpublic/.
Hot module replacement
Editing a component file updates it in place during vite dev:
- A component mounted directly is re-rendered where it stands — between the markers it already occupies, so its position among unrelated siblings is kept — seeded with a snapshot of its current data (props and store values survive; the setup script runs again, so anything it initializes is reset).
- A component used only as a nested definition falls back to a full page reload — already-rendered clones can't be reached from the module that imported the definition.
One caveat: an instance that is both directly mounted and used as a nested definition only refreshes the direct mount.
The swap itself is the runtime's, not the plugin's — the same one the dev server drives for components fetched at runtime. There it can reach the nested clones too, because the runtime tracks instances by filename rather than reaching them through a module.