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:

<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:

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:

Hot module replacement

Editing a component file updates it in place during vite dev:

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.