Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 | 2x 2x 2x 6x 2x 2x 25x 10x 2x 2x 8x 25x 25x 23x 2x 2x 2x 2x 2x 2x 49x 49x 49x 609x 609x 560x 2x 40x 40x 40x 2482x 2482x 2433x 1x 1x 1x 2432x 1x 1x 1x 2431x 20x 20x 20x 6x 6x 6x 2412x 40x 18x 19x 2x 39x 39x 40x 19x 18x 17x 39x 2x 2x 2x 2x 2x 39x 39x 39x 39x 141x 69x 69x 72x 72x 3x 3x 3x 72x 39x 2x 2x 2x 2x 85x 85x 2x 19x 19x 11x 11x 1x 1x 1x 19x 19x 19x 11x 11x 1x 1x 19x 2x 2x 2x 40x 40x 34x 34x 2x 15x 15x 15x 2x 2x 2x 6x 6x 6x 6x 6x 6x 6x 2x 39x 39x 40x 40x 9x 9x 9x 6x 6x 9x 39x 39x 39x 40x 40x 9x 9x 39x 2x 39x 39x 17x 39x 39x 39x 39x 3x 25x 25x 25x 25x 25x 25x 25x 13x 1x 1x 3x 3x 2x 2x 1x 1x 1x 128x 121x 108x 106x 23x 23x 21x 20x 118x 112x 39x 39x 39x 39x 39x 39x 8x 8x 1x 1x 1x 1x 7x 7x 8x | import { readFile } from "node:fs/promises"
import { basename, relative } from "node:path"
import { preprocessCSS, transformWithEsbuild } from "vite"
import type { Plugin, ResolvedConfig } from "vite"
// by package name rather than ../src: this file's types are emitted with dev/
// as their root, and the generator is the runtime's own code - resolved through
// the package's exports (external in the build, aliased to src in the tests)
import { precompile, precompiledScript } from "jq79/precompile"
// Vite plugin: import .html single-file components as modules.
//
// import { jq79 } from "jq79/vite" // vite.config
// import UserCard from "./UserCard.html" // app code
//
// The imported value is a Component79 built from the file's source - the same
// thing `await Component79.fetch(url)` resolves to, but bundled at build time
// instead of fetched at runtime. The component source is inlined verbatim, so
// a file keeps working unchanged if it's ever served from public/ and loaded
// with fetch instead - with one deliberate exception, a block that says it is
// written in something else: <style lang="scss"> (or less/stylus/sass) is
// compiled to plain CSS here, and a TypeScript script - <script lang="ts">, or
// the same mark spelled <script type="text/typescript"> - to plain JS. Such a
// component only works through the bundler; loaded with fetch() it would reach
// the runtime uncompiled, which the runtime warns about.
//
// Only .html files imported from other modules are claimed; entry points
// (index.html) have no importer and imports carrying an explicit query
// (?raw, ?url) keep their built-in Vite meaning.
export interface Jq79PluginOptions {
// which import specifiers are treated as components (default: any .html)
include?: RegExp
// resolved absolute paths to skip even when `include` matches
exclude?: RegExp
// run the app without eval, for a CSP with no 'unsafe-eval' - the value
// Component79.safeEval() takes, and meaning the same thing
// (RECORD/2026-09-23.no-unsafe-eval.md). Every component is precompiled, and
// its functions ship as a script of their own. `true`: those and nothing
// else - whatever the build couldn't see is reported, never evaluated.
// `{ nonce: true }`: whatever it couldn't see (a component built from a
// string, an edit during HMR) is built as a <script> carrying the page's nonce
safeEval?: boolean | { nonce?: boolean }
}
// claimed modules get this suffix so their id no longer ends in ".html" and
// Vite's own html handling (entries, asset pipeline) leaves them alone
const COMPONENT_QUERY = "?jq79"
// safeEval. A component's precompiled functions travel as a classic script of
// their own - Card.html.jq79.js - and not inside its module: they compile
// under `with`, which is a SyntaxError in strict code, and every module is
// strict. In a build the script is an asset beside the chunk; in dev the
// server hands it out (PRECOMPILED_PATH). Either way it pushes its functions
// onto the queue the runtime drains (see "safe eval" in src/jq79.ts).
//
// The component's module waits for it - a top-level await - before it builds
// anything, through a module every component module imports. That module
// evaluates once, so safe mode is turned on once per app, and a page with no
// nonce is reported once rather than once per component; and it loads each
// script once, however many modules ask. The <script> carries the page's
// nonce when there is one, so a CSP that works by nonce admits it; under
// `script-src 'self'` the URL is what admits it.
//
// Component79 is passed in rather than imported, so the module resolves
// nothing: a bare "jq79" from a virtual importer is the one resolution that
// would depend on how the app is laid out.
//
// A component built from a string *before* any .html module is imported runs
// in the default mode; such an app calls Component79.safeEval itself. The
// await needs a build target with top-level await in it - Vite 7 and later
// have one by default; on Vite 5 or 6, raise build.target to es2022
const SAFE_EVAL_ID = "virtual:jq79/safe-eval"
const RESOLVED_SAFE_EVAL_ID = `\0${SAFE_EVAL_ID}`
const safeEvalModule = (options: { nonce?: boolean; worker: false }) => `
let started = null
const loading = new Map()
const pageNonce = () => {
for (const script of document.querySelectorAll("script[nonce]")) {
const nonce = script.nonce || script.getAttribute("nonce")
if (nonce) return nonce
}
}
export const safeEval = (Component79, url) => {
if (!started) started = Component79.safeEval(${JSON.stringify(options)}).catch(error => console.error(error))
let loaded = loading.get(url)
if (!loaded) {
loaded = new Promise((resolve, reject) => {
const script = document.createElement("script")
const nonce = pageNonce()
if (nonce) script.setAttribute("nonce", nonce)
script.src = url
script.onload = () => { script.remove(); resolve() }
script.onerror = () => {
script.remove()
reject(new Error("jq79: the precompiled functions at " + url + " did not load, and safeEval can't render without them"))
}
document.head.append(script)
})
loading.set(url, loaded)
}
return loaded
}
`
// where the dev server hands out a component's precompiled script
const PRECOMPILED_PATH = "/@jq79/precompiled.js"
// the plugin's safeEval option, as what the modules pass the runtime: null
// for off, or Component79.safeEval's own options
const safeEvalOptions = (option: Jq79PluginOptions["safeEval"]): { nonce?: boolean } | null => {
if (option === undefined || option === false) return null
return typeof option === "object" && option !== null && option.nonce === true ? { nonce: true } : {}
}
// a scoped-form body: the runtime falls back to the `with` form when one
// doesn't compile, so only the `with` form's failure is worth a word
const SCOPED_BODY_RE = /^(?:let \$t;| return \()/
// a component's precompiled functions, as the classic script that registers
// them (precompiledScript, in jq79/precompile). Each is compiled here first -
// node can, where the page may not - so the script holds only functions that
// parse; one that doesn't ships as null, and the build says so
const componentScript = (source: string, warn: (message: string) => void): string =>
precompiledScript(precompile(source), (params, body) => {
try {
new Function(...params, body)
return true
} catch (error) {
if (!SCOPED_BODY_RE.test(body)) warn(`${(error as Error).message}, in: ${body.length > 120 ? `${body.slice(0, 120)}…` : body}`)
return false
}
})
// a <script> block with its attribute string, so `lang` can be read and the
// body replaced - the same shape as STYLE_BLOCK_RE below, quote-aware so a
// ">" inside an attribute value (`:setup="{ n = a > 1 }"`) doesn't end the tag
const SCRIPT_BLOCK_RE = /<script((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/script\s*>/gi
// import("...") with a literal specifier, tried at word boundaries the
// scanner below reaches (which is what skips $__import and foo.import(...))
const IMPORT_CALL_RE = /import\s*\(\s*(["'])([^"'\n]+?)\1\s*\)/y
// static import statements (factory scripts): optional clause + literal
// specifier. The clause can't contain parens/quotes, so dynamic import()
// and import.meta never match
const STATIC_IMPORT_RE = /import\s*(?:[\w$\s,{}*]+?\s*from\s*)?(["'])([^"'\n]+)\1/y
const skipString = (src: string, start: number): number => {
const quote = src[start]
let i = start + 1
while (i < src.length) {
Iif (src[i] === "\\") { i += 2; continue }
if (src[i] === quote) return i + 1
i++
}
return src.length
}
// the literal import specifiers in one script body. A scanner rather than a
// bare matchAll, because a specifier mentioned in a comment or a string is
// not an import: hoisting a commented-out `import("./old.html")` would pull
// dead files into the bundle - or break the build once the file is gone
const importSpecifiers = (script: string): string[] => {
const specs: string[] = []
let i = 0
while (i < script.length) {
const ch = script[i]
if (ch === "'" || ch === '"' || ch === "`") { i = skipString(script, i); continue }
if (ch === "/" && script[i + 1] === "/") {
const end = script.indexOf("\n", i)
i = end === -1 ? script.length : end + 1
continue
}
if (ch === "/" && script[i + 1] === "*") {
const end = script.indexOf("*/", i + 2)
i = end === -1 ? script.length : end + 2
continue
}
if (ch === "i" && (i === 0 || !/[\w$.]/.test(script[i - 1]))) {
IMPORT_CALL_RE.lastIndex = i
const call = IMPORT_CALL_RE.exec(script)
if (call) { specs.push(call[2]); i = IMPORT_CALL_RE.lastIndex; continue }
STATIC_IMPORT_RE.lastIndex = i
const staticImport = STATIC_IMPORT_RE.exec(script)
if (staticImport) { specs.push(staticImport[2]); i = STATIC_IMPORT_RE.lastIndex; continue }
}
i++
}
return specs
}
const isHtmlUrl = (spec: string) => /\.html?([?#]|$)/.test(spec)
const isExternalUrl = (spec: string) => /^[a-z][a-z0-9+.-]*:/i.test(spec) || spec.startsWith("/")
// literal import specifiers in the component's script blocks - dynamic
// `import("...")` calls and static factory-script imports - that should
// resolve from the bundle instead of at runtime. Absolute paths and full
// URLs are left alone (they point at served files, e.g. public/), and so
// are .html specifiers the plugin wouldn't claim as components
const hoistableImports = (source: string, include: RegExp): string[] => {
const specifiers = new Set<string>()
for (const [, , script] of source.matchAll(SCRIPT_BLOCK_RE)) {
for (const spec of importSpecifiers(script)) {
if (isExternalUrl(spec)) continue
if (isHtmlUrl(spec) && !include.test(spec)) continue // html left to runtime fetch
specifiers.add(spec) // a claimed component, a source file or an npm package
}
}
return [...specifiers]
}
// any start or end tag, quote-aware so a ">" inside an attribute value doesn't
// end it early
const TAG_RE = /<(\/?)([A-Za-z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
const NAME_ATTR_RE = /\bname\s*=\s*(?:"([^"]*)"|'([^']*)')/i
const COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]*$/
const VOID_ELEMENTS = new Set([
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
])
// the components a file declares: its *top-level* <template name="…"> blocks,
// which the emitted module re-exports by name. Depth is tracked because only
// the top level declares - a <template> nested in the markup is a plain inert
// element the runtime leaves alone, and exporting it would name something that
// never exists. Script and style bodies are cut out first, so a "<" in JS or a
// selector can't be read as a tag
const declaredComponents = (source: string): string[] => {
const markup = source.replace(SCRIPT_BLOCK_RE, "").replace(STYLE_BLOCK_RE, "")
const names: string[] = []
let depth = 0
for (const [, closing, tag, attrs] of markup.matchAll(TAG_RE)) {
if (closing) {
depth = Math.max(0, depth - 1)
continue
}
const selfClosing = /\/\s*$/.test(attrs) || VOID_ELEMENTS.has(tag.toLowerCase())
if (depth === 0 && !selfClosing && tag.toLowerCase() === "template") {
const declared = attrs.match(NAME_ATTR_RE)
const name = declared?.[1] ?? declared?.[2]
// the runtime warns about the ones this skips (nameless, not PascalCase)
Eif (name && COMPONENT_NAME_RE.test(name)) names.push(name)
}
if (!selfClosing) depth++
}
return names
}
// a <style> block with its attribute string, so `lang` can be read and the
// content replaced. Attribute values are matched as quoted chunks so a ">"
// inside one doesn't end the tag early
const STYLE_BLOCK_RE = /<style((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/style\s*>/gi
const LANG_ATTR_RE = /\blang\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
const TYPE_ATTR_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
// one attribute's value out of a tag's attribute string, whichever way it was
// quoted - null when the attribute isn't there at all
const attrValue = (attrs: string, re: RegExp): string | null => {
const found = attrs.match(re)
return found ? found[1] ?? found[2] ?? found[3] : null
}
// compiles <style lang="scss|less|styl|sass"> blocks to plain CSS with Vite's
// own preprocessing (the same call @vitejs/plugin-vue makes), so the runtime
// only ever sees CSS. The preprocessor picks its parser from the extension,
// and resolving relative @use/@import against a filename in the component's
// own directory is what makes `@use "./vars"` work. Files the preprocessor
// pulls in are registered as watch deps, so editing a partial re-runs HMR for
// every component that uses it. `lang` is dropped from the emitted tag: what
// the runtime parses is a plain <style> (with `scoped` and the rest intact)
const compileStyleBlocks = async (
source: string,
file: string,
config: ResolvedConfig,
addWatchFile: (id: string) => void
): Promise<string> => {
const blocks = [...source.matchAll(STYLE_BLOCK_RE)]
const compiled = await Promise.all(
blocks.map(async ([, attrs, content]) => {
const extension = attrValue(attrs, LANG_ATTR_RE)
if (extension === null) return null
const result = await preprocessCSS(content, `${file}.${extension}`, config)
result.deps?.forEach(addWatchFile)
return { attrs: attrs.replace(LANG_ATTR_RE, "").trimEnd(), css: result.code }
})
)
let out = ""
let last = 0
blocks.forEach((block, i) => {
const done = compiled[i]
if (!done) return
out += source.slice(last, block.index) + `<style${done.attrs}>${done.css}</style>`
last = block.index + block[0].length
})
return out + source.slice(last)
}
// languages a <script lang> is compiled from. Anything else is left as written
// for the runtime to warn about, rather than guessed at
const TS_LANGS = new Set(["ts", "typescript"])
// the other spelling of the same mark. A component is a plain .html file, not
// an SFC, so nothing in an editor knows what `lang` means there, and each mark
// gets half of one: VS Code's HTML language service checks a `type` of
// text/typescript as TypeScript (and `lang="ts"` as JavaScript), while its
// grammar colors that `type` not at all (and `lang="ts"` as JavaScript). See
// docs/vite-plugin.md#which-mark-in-vs-code. The `x-` forms are the historical
// spelling of the same two media types
const TS_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/
// what marks a script block as TypeScript, given back as the attribute to drop
// from the emitted tag - the block is compiled to JS, so the mark goes with the
// types whichever one carried it. A `lang` answers on its own: `lang="coffee"`
// is a language this plugin doesn't compile, and reading `type` past it would
// compile a block the author said was something else
const typescriptAttr = (attrs: string): RegExp | null => {
const lang = attrValue(attrs, LANG_ATTR_RE)
if (lang !== null) return TS_LANGS.has(lang.trim().toLowerCase()) ? LANG_ATTR_RE : null
const type = attrValue(attrs, TYPE_ATTR_RE)
return type !== null && TS_TYPE_RE.test(type.trim().toLowerCase()) ? TYPE_ATTR_RE : null
}
type ViteTransform = (code: string, id: string, options?: unknown) => Promise<{ code: string }>
// what strips the types: vite's own transform, so the plugin carries no
// compiler of its own. *Which* transform is a version question, and neither
// answer covers the peer range (vite >= 5) alone - transformWithOxc is the one
// vite is moving to but only exists from vite 7, while transformWithEsbuild is
// deprecated under vite 8 and throws there unless esbuild is installed
// separately. So it is looked up at call time: oxc where it exists, esbuild on
// the older versions that ship it.
//
// Both drop unused value imports by default, because a TS transform can't tell
// a type-only import from an unused one. That would be silent damage here: a
// factory script's `import Row from "./row.html"` would vanish, taking with it
// the specifier hoistableImports needs to pull the child into the bundle. The
// flags below are what keep it - only `import type` is erased
const stripTypes = async (ts: string, file: string): Promise<string> => {
const vite = (await import("vite")) as unknown as { transformWithOxc?: ViteTransform }
const { code } = vite.transformWithOxc
? await vite.transformWithOxc(ts, file, { lang: "ts", typescript: { onlyRemoveTypeImports: true } })
: await transformWithEsbuild(ts, file, {
loader: "ts",
tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
})
// a script whose only imports were `import type` comes back marked as a
// module. `export {}` exports nothing, and it is a SyntaxError inside the
// Function body the runtime compiles a script into
return code.replace(/^[ \t]*export\s*\{\s*\}\s*;?[ \t]*$/m, "")
}
// the `:setup` attribute, when it carries a value (a bare `:setup` is the
// closed signature and can't be typed)
const SETUP_ATTR_RE = /(:setup\s*=\s*)(?:"([^"]*)"|'([^']*)')/i
// the arrow body the signature is wrapped in, so the parameter list can be
// found again in the output without matching brackets
const SIGNATURE_MARKER = "__jq79_signature__"
// a component's props signature lives in the `:setup` *attribute*, not in the
// script body, so the body's transform never sees it - and the TypeScript mark
// has to mean the same thing on both halves of the block, or a typed signature
// either survives into a component the plugin just promised was JS or, for
// `_: Props`, stops reading as a signature at all.
//
// It goes through the same transform as everything else, wrapped as a
// parameter list, rather than being cut with a scanner of its own: the
// annotation can sit on the pattern (`{ a }: Props`), on the permissive `_`, or
// inside a default (`{ step = 1 as number }`, which the runtime would evaluate
// and silently drop), and telling those apart is a parser's job. Both transforms
// hand back `(<params>) => <marker>` on one line, parens intact
const stripSignatureTypes = async (value: string, file: string): Promise<string> => {
const compiled = await stripTypes(`(${value}) => ${SIGNATURE_MARKER}`, file)
const marker = compiled.indexOf(SIGNATURE_MARKER)
Iif (marker === -1) return value // not the shape expected: leave it as written
const head = compiled.slice(0, marker).trimEnd()
const params = (head.endsWith("=>") ? head.slice(0, -2) : head).trim()
return params.startsWith("(") && params.endsWith(")") ? params.slice(1, -1).trim() : params
}
// the signature back into a double-quoted attribute. Only `"` needs escaping:
// the transforms normalize string literals to double quotes, so a default the
// author wrote as `'x'` comes back as `"x"` and would end the attribute early.
// `&` is deliberately left alone - `&&` in a default is not an entity and
// survives the parse, while escaping it would double-encode a source that
// already wrote `"`
const quoteAttrValue = (value: string) => value.replace(/"/g, """)
// compiles TypeScript script blocks to plain JS, so the runtime only ever sees
// JS - the same deal <style lang="scss"> gets, for a sharper reason. The setup
// scanner is not a parser: `let count: number = 0` reaching it is not a syntax
// error but a labeled statement that assigns to `number`, so it runs and leaves
// `count` undeclared. Whichever attribute marked the block (`lang="ts"` or
// `type="text/typescript"`) is dropped from the emitted tag and every other one
// (`:setup`, `:mounted`) is left as written.
//
// This runs before hoistableImports reads the source, so an `import type`
// specifier is already gone by the time the plugin decides what to bundle
const compileScriptBlocks = async (source: string, file: string): Promise<string> => {
const blocks = [...source.matchAll(SCRIPT_BLOCK_RE)]
const compiled = await Promise.all(
blocks.map(async ([, attrs, content]) => {
const marker = typescriptAttr(attrs)
if (!marker) return null
let rest = attrs.replace(marker, "").trimEnd()
const setup = rest.match(SETUP_ATTR_RE)
if (setup) {
const signature = await stripSignatureTypes(setup[2] ?? setup[3], `${file}.signature.ts`)
// a function replacement, not a string: `$&` and friends are live in a
// replacement string and a default value is arbitrary source
rest = rest.replace(SETUP_ATTR_RE, () => `${setup[1]}"${quoteAttrValue(signature)}"`)
}
return { attrs: rest, js: await stripTypes(content, `${file}.ts`) }
})
)
let out = ""
let last = 0
blocks.forEach((block, i) => {
const done = compiled[i]
if (!done) return
out += source.slice(last, block.index) + `<script${done.attrs}>${done.js}</script>`
last = block.index + block[0].length
})
return out + source.slice(last)
}
// the emitted module. Literal import("...") specifiers found in the
// component's scripts become real module imports, handed to Component79 as a
// resolution map: at runtime $__import checks the map before falling back to
// fetch, so bundled components ship with their imports and nothing changes
// for unbundled ones. Claimed components import as their default (a
// Component79, matching what runtime fetch resolves to); everything else as
// a namespace (matching native import()).
//
// A file's <template name="…"> components are re-exported by name, so the
// module shape is the one the file already has: default plus named. They read
// off the instance, which is where the runtime hangs them - so in dev they are
// bound to the *first* evaluation's definitions and a module that imports one
// by name keeps the pre-edit child until the page reloads. The file's own
// component patches in place, and its rendered children come from the reparse,
// so this only shows in a component imported by name from another file.
//
// In dev, `hot.data` carries the exported instance across updates: importers
// hold a reference to the *first* module evaluation's instance, so later
// evaluations patch that same instance in place instead of exporting a new one
// nobody sees. The patching itself is the runtime's `hotReplace` - the same
// swap the jq79/dev server drives, from the one place that can reach a
// component's markers. An instance only used as a definition has nothing to
// re-render (nested clones can't be reached from this module), so it falls
// back to a full reload.
// `precompiled` is the precompiled script's URL, as a JS expression - present
// only in safeEval mode
const componentModule = (source: string, include: RegExp, filename: string, precompiled?: string): string => {
const hoisted = hoistableImports(source, include)
const imports = hoisted
.map((spec, i) =>
include.test(spec)
? `import __jq79_${i} from ${JSON.stringify(spec)}`
: `import * as __jq79_${i} from ${JSON.stringify(spec)}`
)
.join("\n")
const modulesMap = `{ ${hoisted.map((spec, i) => `${JSON.stringify(spec)}: __jq79_${i}`).join(", ")} }`
// without safeEval, the module is exactly what it always was
const safeEvalImport = precompiled ? `\nimport { safeEval } from "${SAFE_EVAL_ID}"` : ""
const safeEvalCall = precompiled ? `\nawait safeEval(Component79, ${precompiled})` : ""
return `
import { Component79 } from "jq79"${safeEvalImport}
${imports}${safeEvalCall}
const src = ${JSON.stringify(source)}
const modules = ${modulesMap}
const filename = ${JSON.stringify(filename)}
let component
if (import.meta.hot && import.meta.hot.data.component) {
const prior = import.meta.hot.data.component
prior.modules = modules
prior.filename = filename
// re-renders it where it stands, keeping its data. false means it was never
// rendered - a definition used only as a nested component - and a reload is
// the only way to reach the clones made from it
if (!prior.hotReplace(src) && !prior.data) import.meta.hot.invalidate()
component = prior
} else {
component = new Component79(src, { modules, filename })
}
if (import.meta.hot) {
import.meta.hot.data.component = component
import.meta.hot.accept()
}
export default component
${declaredComponents(source).map(name => `export const ${name} = component.${name}`).join("\n")}
`
}
export function jq79(options: Jq79PluginOptions = {}): Plugin {
const include = options.include ?? /\.html$/
const { exclude } = options
const safeEval = safeEvalOptions(options.safeEval)
// dev only: each component's precompiled script, by the id its module asks
// for - so the server hands out what the plugin compiled and nothing else
const devScripts = new Map<string, string>()
const devIds = new Map<string, number>()
let config: ResolvedConfig | null = null
return {
name: "jq79",
enforce: "pre",
configResolved(resolved) {
config = resolved
},
configureServer(server) {
Iif (!safeEval) return
server.middlewares.use((req, res, next) => {
const url = new URL(req.url ?? "/", "http://localhost")
if (!url.pathname.endsWith(PRECOMPILED_PATH)) return next()
const script = devScripts.get(url.searchParams.get("id") ?? "")
if (script === undefined) return next()
res.setHeader("Content-Type", "text/javascript")
res.setHeader("Cache-Control", "no-cache")
res.end(script)
})
},
async resolveId(source, importer) {
if (source === SAFE_EVAL_ID) return RESOLVED_SAFE_EVAL_ID
if (!importer) return null // entry points are never components
if (source.includes("?")) return null // ?raw, ?url, ... keep their meaning
if (!include.test(source)) return null
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (!resolved || resolved.external) return null
if (exclude?.test(resolved.id)) return null
return resolved.id + COMPONENT_QUERY
},
async load(id) {
// a Vite page's functions come from the build, so it never registers the worker
if (id === RESOLVED_SAFE_EVAL_ID) return safeEvalModule({ ...safeEval, worker: false })
if (!id.endsWith(COMPONENT_QUERY)) return null
const file = id.slice(0, -COMPONENT_QUERY.length)
let source = await readFile(file, "utf8")
source = await compileScriptBlocks(source, file)
if (config) source = await compileStyleBlocks(source, file, config, dep => this.addWatchFile(dep))
// the runtime names the component's setup scripts after this, so devtools
// shows a path the user recognizes instead of an anonymous VM script
const filename = config ? relative(config.root, file) : file
if (!safeEval) return { code: componentModule(source, include, filename), map: null }
// the component's functions, compiled after its TypeScript and styles are:
// the runtime compiles what reaches it, and this is what reaches it
const script = componentScript(source, message => this.warn(`jq79: ${filename}: ${message}`))
let url: string
if (config?.command === "serve") {
let devId = devIds.get(file)
Eif (devId === undefined) devIds.set(file, devId = devIds.size)
devScripts.set(String(devId), script)
// versioned, so an edit loads the new functions rather than a cached copy
url = JSON.stringify(`${config.base}${PRECOMPILED_PATH.slice(1)}?id=${devId}&v=${Date.now()}`)
} else {
const ref = this.emitFile({ type: "asset", name: `${basename(file)}.jq79.js`, source: script })
url = `import.meta.ROLLUP_FILE_URL_${ref}`
}
return { code: componentModule(source, include, filename, url), map: null }
},
}
}
export default jq79
|