Vite plugin

The plugin is optional. jq79 works without any build tool — components are .html files the browser already knows how to fetch. The plugin is only needed when you want bundled imports, HMR through Vite's dev server, CSS preprocessing (<style lang="scss">) or TypeScript (<script lang="ts">). A file with neither works unchanged in all three delivery modes: bundled, fetched at runtime, or served by the dev server.

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 a block that says it is written in something else (<style lang="scss">, <script lang="ts">). Without one, the same .html file works unchanged in all three delivery modes:

The one thing the plugin can add is safeEval: each component's functions, precompiled into a script beside the bundle. The source still travels verbatim; the precompiled script sits next to it.

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

<script lang="ts"> — TypeScript

A script block with lang="ts" is compiled to plain JS by the plugin, through Vite's own transform. It works on both kinds of script — setup and factory — and the mark is the only attribute that changes; :setup, :mounted and the rest are left as written (the prop signature has its own section below):

<script :setup="{ step = 1 }" lang="ts">
  import type { User } from "./types"

  interface Row { id: number; label: string }

  const rows: Row[] = []
  let count: number = 0

  $: doubled = twice(count)

  const twice = (n: number): number => n * step
</script>

type="text/typescript" is the same mark. The plugin compiles either one, and drops whichever attribute carried it. Which one to write is a question of what your editor does with it, not of what the plugin does:

<script :setup="{ step = 1 }: Props" type="text/typescript">
  let count: number = 0
</script>

text/typescript, application/typescript and their x- spellings all count. Any other type is somebody else's attribute and is left alone — module included, which is real HTML the plugin has no business rewriting.

The types are erased, nothing else moves: top-level let/const still become reactive store variables, $: is still a reactive declaration, and import(…) specifiers are still hoisted into the bundle. import type is erased with the rest, so a types-only module never reaches the bundle.

The prop signature is compiled too. It lives in the :setup attribute, which the body's transform never sees, so lang reaches it separately — a typed signature is stripped back to the pattern the runtime reads:

<script :setup="{ label = 'Total', step = 1 }: Props" lang="ts">   →   :setup="{ label = &quot;Total&quot;, step = 1 }"
<script :setup="_: Props" lang="ts">                               →   :setup="_"

Without that, { … }: Props would leave TypeScript inside a component the plugin just called JS, and _: Props would stop reading as the permissive signature at all and warn. A factory script declares its props in its first parameter, which is in the body, so it is already covered by the body's own transform.

It strips types; it does not check them. There is no .html-aware type-checker yet. What you get is annotations that survive the runtime, not a checked component.

lang="ts" ties a component to the bundler, exactly as lang="scss" does — and the runtime says so, for a sharper reason than it does with styles. A missing type-strip is not always a loud failure: interface, as and generics throw when the script is compiled, but let count: number = 0 is a valid labeled statement, so it runs, assigns to number, and leaves count undeclared and non-reactive without a word. So parsing a component whose <script> still carries either mark logs a warning naming the plugin. If a component must work unbundled too, write plain JS.

One known limitation: the script devtools shows is the compiled one, so for a typed block the line numbers drift from the .html's — a multi-line annotation collapses. The plugin doesn't emit a source map for the inlined source today.

Which mark, in VS Code

A component is a plain .html file, not a .vue or .svelte SFC, so nothing in VS Code knows what lang means in one. It reads an inline script twice, and each pass looks at something different. Its coloring only knows the JavaScript types. Its checking takes the language from type, and treats no type as JavaScript. So each mark gets half:

in VS Code lang="ts" type="text/typescript"
coloring as JavaScript, which reads TypeScript well enough none
errors, completion as JavaScript: a red line under every annotation as TypeScript, each script on its own: props, $: targets and $mounted are "Cannot find name"

Neither gets checking that understands a setup script: VS Code's own service doesn't know jq79's implicit names.

The extension in this repo (not on the Marketplace yet) does. It colors both marks as TypeScript, and <style lang> in the language it names, and it runs a language server that checks, completes and hovers a component's scripts the way the runtime compiles them: the :setup pattern types the props, $: declares its target, the scripts of a component share their names, and the helpers ($mounted, $emit, …) are in scope, typed. A lang="ts" script is type-checked; a JavaScript one is when checkJs is on, as any JS file. The template's expressions are checked against that store in every component, JavaScript ones included.

It also turns VS Code's own script checks off (html.validate.scripts), which would otherwise report the same scripts again, wrongly. That goes for every inline script in .html files, pages included; set it back to true if you want it there. The same checks run from a terminal, for CI, with jq79-check.

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.

A file that declares several components exports them by name, next to its own as the default:

import List, { Row } from "./list.html"

Only top-level <template name="…"> blocks are exported, and only PascalCase names — the same ones the runtime accepts.

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\//,
  // run without 'unsafe-eval': true, or { nonce: true } (default: off)
  safeEval: true,
})

safeEval — a CSP without 'unsafe-eval'

jq79({ safeEval: true })              // Content-Security-Policy: script-src 'self'
jq79({ safeEval: { nonce: true } })   // Content-Security-Policy: script-src 'nonce-…'

Every imported component is precompiled at build time, and its functions ship as a script of their own beside the bundle — UserCard.html.jq79-[hash].js — which the component's module waits for before it exports the component. The runtime then never calls new Function: with true, anything that wasn't precompiled is reported in the console instead; with { nonce: true }, it is built as a <script> carrying the page's nonce. The build reports an expression that doesn't compile, with its file.

The wait is a top-level await — Vite 7 and later target it by default; on Vite 5 or 6, set build.target: "es2022". The whole story, the no-bundler route included, is in Content Security Policy.

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.

For a file that declares several components, the edit re-renders the file's own component, and its children come back from the same reparse. A component imported by name from another module is the nested-definition case above: that module keeps the pre-edit definition until the page reloads.

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.