jq79 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -178,6 +178,7 @@ When the fetch resolves, the assignments to `firstName`/`lastName` re-run the `$
178
178
  - [Reactive data](docs/reactive-data.md) — the standalone `$reactive` store: `$on`, `$onAny`, `$effect`.
179
179
  - [DOM helpers](docs/dom-helpers.md) — `$`, `$$` and `$create`.
180
180
  - [Vite plugin](docs/vite-plugin.md) — importing `.html` components as bundled modules, HMR, options.
181
+ - [Content Security Policy](docs/csp.md) — `safeEval`: running without `'unsafe-eval'`, with Vite or with no bundler at all.
181
182
  - [Dev server](docs/dev-server.md) — `npx jq79 dev`: serve and hot-reload components with no build step.
182
183
  - [Development](docs/development.md) — running tests, building, publishing releases.
183
184
 
package/dev/vite.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { readFile } from "node:fs/promises"
2
- import { relative } from "node:path"
2
+ import { basename, relative } from "node:path"
3
3
  import { preprocessCSS, transformWithEsbuild } from "vite"
4
4
  import type { Plugin, ResolvedConfig } from "vite"
5
+ // by package name rather than ../src: this file's types are emitted with dev/
6
+ // as their root, and the generator is the runtime's own code - resolved through
7
+ // the package's exports (external in the build, aliased to src in the tests)
8
+ import { precompile, precompiledScript } from "jq79/precompile"
5
9
 
6
10
  // Vite plugin: import .html single-file components as modules.
7
11
  //
@@ -12,11 +16,12 @@ import type { Plugin, ResolvedConfig } from "vite"
12
16
  // thing `await Component79.fetch(url)` resolves to, but bundled at build time
13
17
  // instead of fetched at runtime. The component source is inlined verbatim, so
14
18
  // a file keeps working unchanged if it's ever served from public/ and loaded
15
- // with fetch instead - with one deliberate exception, `lang`: <style lang="scss">
16
- // (or less/stylus/sass) is compiled to plain CSS here, and <script lang="ts"> to
17
- // plain JS. A component using `lang` therefore only works through the bundler;
18
- // loaded with fetch() it would reach the runtime uncompiled, which the runtime
19
- // warns about.
19
+ // with fetch instead - with one deliberate exception, a block that says it is
20
+ // written in something else: <style lang="scss"> (or less/stylus/sass) is
21
+ // compiled to plain CSS here, and a TypeScript script - <script lang="ts">, or
22
+ // the same mark spelled <script type="text/typescript"> - to plain JS. Such a
23
+ // component only works through the bundler; loaded with fetch() it would reach
24
+ // the runtime uncompiled, which the runtime warns about.
20
25
  //
21
26
  // Only .html files imported from other modules are claimed; entry points
22
27
  // (index.html) have no importer and imports carrying an explicit query
@@ -27,12 +32,105 @@ export interface Jq79PluginOptions {
27
32
  include?: RegExp
28
33
  // resolved absolute paths to skip even when `include` matches
29
34
  exclude?: RegExp
35
+ // run the app without eval, for a CSP with no 'unsafe-eval' - the value
36
+ // Component79.safeEval() takes, and meaning the same thing
37
+ // (RECORD/2026-09-23.no-unsafe-eval.md). Every component is precompiled, and
38
+ // its functions ship as a script of their own. `true`: those and nothing
39
+ // else - whatever the build couldn't see is reported, never evaluated.
40
+ // `{ nonce: true }`: whatever it couldn't see (a component built from a
41
+ // string, an edit during HMR) is built as a <script> carrying the page's nonce
42
+ safeEval?: boolean | { nonce?: boolean }
30
43
  }
31
44
 
32
45
  // claimed modules get this suffix so their id no longer ends in ".html" and
33
46
  // Vite's own html handling (entries, asset pipeline) leaves them alone
34
47
  const COMPONENT_QUERY = "?jq79"
35
48
 
49
+ // safeEval. A component's precompiled functions travel as a classic script of
50
+ // their own - Card.html.jq79.js - and not inside its module: they compile
51
+ // under `with`, which is a SyntaxError in strict code, and every module is
52
+ // strict. In a build the script is an asset beside the chunk; in dev the
53
+ // server hands it out (PRECOMPILED_PATH). Either way it pushes its functions
54
+ // onto the queue the runtime drains (see "safe eval" in src/jq79.ts).
55
+ //
56
+ // The component's module waits for it - a top-level await - before it builds
57
+ // anything, through a module every component module imports. That module
58
+ // evaluates once, so safe mode is turned on once per app, and a page with no
59
+ // nonce is reported once rather than once per component; and it loads each
60
+ // script once, however many modules ask. The <script> carries the page's
61
+ // nonce when there is one, so a CSP that works by nonce admits it; under
62
+ // `script-src 'self'` the URL is what admits it.
63
+ //
64
+ // Component79 is passed in rather than imported, so the module resolves
65
+ // nothing: a bare "jq79" from a virtual importer is the one resolution that
66
+ // would depend on how the app is laid out.
67
+ //
68
+ // A component built from a string *before* any .html module is imported runs
69
+ // in the default mode; such an app calls Component79.safeEval itself. The
70
+ // await needs a build target with top-level await in it - Vite 7 and later
71
+ // have one by default; on Vite 5 or 6, raise build.target to es2022
72
+ const SAFE_EVAL_ID = "virtual:jq79/safe-eval"
73
+ const RESOLVED_SAFE_EVAL_ID = `\0${SAFE_EVAL_ID}`
74
+ const safeEvalModule = (options: { nonce?: boolean; worker: false }) => `
75
+ let started = null
76
+ const loading = new Map()
77
+ const pageNonce = () => {
78
+ for (const script of document.querySelectorAll("script[nonce]")) {
79
+ const nonce = script.nonce || script.getAttribute("nonce")
80
+ if (nonce) return nonce
81
+ }
82
+ }
83
+ export const safeEval = (Component79, url) => {
84
+ if (!started) started = Component79.safeEval(${JSON.stringify(options)}).catch(error => console.error(error))
85
+ let loaded = loading.get(url)
86
+ if (!loaded) {
87
+ loaded = new Promise((resolve, reject) => {
88
+ const script = document.createElement("script")
89
+ const nonce = pageNonce()
90
+ if (nonce) script.setAttribute("nonce", nonce)
91
+ script.src = url
92
+ script.onload = () => { script.remove(); resolve() }
93
+ script.onerror = () => {
94
+ script.remove()
95
+ reject(new Error("jq79: the precompiled functions at " + url + " did not load, and safeEval can't render without them"))
96
+ }
97
+ document.head.append(script)
98
+ })
99
+ loading.set(url, loaded)
100
+ }
101
+ return loaded
102
+ }
103
+ `
104
+
105
+ // where the dev server hands out a component's precompiled script
106
+ const PRECOMPILED_PATH = "/@jq79/precompiled.js"
107
+
108
+ // the plugin's safeEval option, as what the modules pass the runtime: null
109
+ // for off, or Component79.safeEval's own options
110
+ const safeEvalOptions = (option: Jq79PluginOptions["safeEval"]): { nonce?: boolean } | null => {
111
+ if (option === undefined || option === false) return null
112
+ return typeof option === "object" && option !== null && option.nonce === true ? { nonce: true } : {}
113
+ }
114
+
115
+ // a scoped-form body: the runtime falls back to the `with` form when one
116
+ // doesn't compile, so only the `with` form's failure is worth a word
117
+ const SCOPED_BODY_RE = /^(?:let \$t;| return \()/
118
+
119
+ // a component's precompiled functions, as the classic script that registers
120
+ // them (precompiledScript, in jq79/precompile). Each is compiled here first -
121
+ // node can, where the page may not - so the script holds only functions that
122
+ // parse; one that doesn't ships as null, and the build says so
123
+ const componentScript = (source: string, warn: (message: string) => void): string =>
124
+ precompiledScript(precompile(source), (params, body) => {
125
+ try {
126
+ new Function(...params, body)
127
+ return true
128
+ } catch (error) {
129
+ if (!SCOPED_BODY_RE.test(body)) warn(`${(error as Error).message}, in: ${body.length > 120 ? `${body.slice(0, 120)}…` : body}`)
130
+ return false
131
+ }
132
+ })
133
+
36
134
  // a <script> block with its attribute string, so `lang` can be read and the
37
135
  // body replaced - the same shape as STYLE_BLOCK_RE below, quote-aware so a
38
136
  // ">" inside an attribute value (`:setup="{ n = a > 1 }"`) doesn't end the tag
@@ -152,6 +250,14 @@ const declaredComponents = (source: string): string[] => {
152
250
  // inside one doesn't end the tag early
153
251
  const STYLE_BLOCK_RE = /<style((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/style\s*>/gi
154
252
  const LANG_ATTR_RE = /\blang\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
253
+ const TYPE_ATTR_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
254
+
255
+ // one attribute's value out of a tag's attribute string, whichever way it was
256
+ // quoted - null when the attribute isn't there at all
257
+ const attrValue = (attrs: string, re: RegExp): string | null => {
258
+ const found = attrs.match(re)
259
+ return found ? found[1] ?? found[2] ?? found[3] : null
260
+ }
155
261
 
156
262
  // compiles <style lang="scss|less|styl|sass"> blocks to plain CSS with Vite's
157
263
  // own preprocessing (the same call @vitejs/plugin-vue makes), so the runtime
@@ -170,9 +276,8 @@ const compileStyleBlocks = async (
170
276
  const blocks = [...source.matchAll(STYLE_BLOCK_RE)]
171
277
  const compiled = await Promise.all(
172
278
  blocks.map(async ([, attrs, content]) => {
173
- const lang = attrs.match(LANG_ATTR_RE)
174
- if (!lang) return null
175
- const extension = lang[1] ?? lang[2] ?? lang[3]
279
+ const extension = attrValue(attrs, LANG_ATTR_RE)
280
+ if (extension === null) return null
176
281
  const result = await preprocessCSS(content, `${file}.${extension}`, config)
177
282
  result.deps?.forEach(addWatchFile)
178
283
  return { attrs: attrs.replace(LANG_ATTR_RE, "").trimEnd(), css: result.code }
@@ -193,6 +298,26 @@ const compileStyleBlocks = async (
193
298
  // languages a <script lang> is compiled from. Anything else is left as written
194
299
  // for the runtime to warn about, rather than guessed at
195
300
  const TS_LANGS = new Set(["ts", "typescript"])
301
+ // the other spelling of the same mark. A component is a plain .html file, not
302
+ // an SFC, so nothing in an editor knows what `lang` means there, and each mark
303
+ // gets half of one: VS Code's HTML language service checks a `type` of
304
+ // text/typescript as TypeScript (and `lang="ts"` as JavaScript), while its
305
+ // grammar colors that `type` not at all (and `lang="ts"` as JavaScript). See
306
+ // docs/vite-plugin.md#which-mark-in-vs-code. The `x-` forms are the historical
307
+ // spelling of the same two media types
308
+ const TS_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/
309
+
310
+ // what marks a script block as TypeScript, given back as the attribute to drop
311
+ // from the emitted tag - the block is compiled to JS, so the mark goes with the
312
+ // types whichever one carried it. A `lang` answers on its own: `lang="coffee"`
313
+ // is a language this plugin doesn't compile, and reading `type` past it would
314
+ // compile a block the author said was something else
315
+ const typescriptAttr = (attrs: string): RegExp | null => {
316
+ const lang = attrValue(attrs, LANG_ATTR_RE)
317
+ if (lang !== null) return TS_LANGS.has(lang.trim().toLowerCase()) ? LANG_ATTR_RE : null
318
+ const type = attrValue(attrs, TYPE_ATTR_RE)
319
+ return type !== null && TS_TYPE_RE.test(type.trim().toLowerCase()) ? TYPE_ATTR_RE : null
320
+ }
196
321
 
197
322
  type ViteTransform = (code: string, id: string, options?: unknown) => Promise<{ code: string }>
198
323
 
@@ -231,10 +356,10 @@ const SETUP_ATTR_RE = /(:setup\s*=\s*)(?:"([^"]*)"|'([^']*)')/i
231
356
  const SIGNATURE_MARKER = "__jq79_signature__"
232
357
 
233
358
  // a component's props signature lives in the `:setup` *attribute*, not in the
234
- // script body, so the body's transform never sees it - and `lang="ts"` has to
235
- // mean the same thing on both halves of the block, or a typed signature either
236
- // survives into a component the plugin just promised was JS or, for `_: Props`,
237
- // stops reading as a signature at all.
359
+ // script body, so the body's transform never sees it - and the TypeScript mark
360
+ // has to mean the same thing on both halves of the block, or a typed signature
361
+ // either survives into a component the plugin just promised was JS or, for
362
+ // `_: Props`, stops reading as a signature at all.
238
363
  //
239
364
  // It goes through the same transform as everything else, wrapped as a
240
365
  // parameter list, rather than being cut with a scanner of its own: the
@@ -259,12 +384,13 @@ const stripSignatureTypes = async (value: string, file: string): Promise<string>
259
384
  // already wrote `&quot;`
260
385
  const quoteAttrValue = (value: string) => value.replace(/"/g, "&quot;")
261
386
 
262
- // compiles <script lang="ts"> blocks to plain JS, so the runtime only ever sees
387
+ // compiles TypeScript script blocks to plain JS, so the runtime only ever sees
263
388
  // JS - the same deal <style lang="scss"> gets, for a sharper reason. The setup
264
389
  // scanner is not a parser: `let count: number = 0` reaching it is not a syntax
265
390
  // error but a labeled statement that assigns to `number`, so it runs and leaves
266
- // `count` undeclared. `lang` is dropped from the emitted tag and every other
267
- // attribute (`:setup`, `:mounted`) is left as written.
391
+ // `count` undeclared. Whichever attribute marked the block (`lang="ts"` or
392
+ // `type="text/typescript"`) is dropped from the emitted tag and every other one
393
+ // (`:setup`, `:mounted`) is left as written.
268
394
  //
269
395
  // This runs before hoistableImports reads the source, so an `import type`
270
396
  // specifier is already gone by the time the plugin decides what to bundle
@@ -272,11 +398,10 @@ const compileScriptBlocks = async (source: string, file: string): Promise<string
272
398
  const blocks = [...source.matchAll(SCRIPT_BLOCK_RE)]
273
399
  const compiled = await Promise.all(
274
400
  blocks.map(async ([, attrs, content]) => {
275
- const lang = attrs.match(LANG_ATTR_RE)
276
- const name = (lang?.[1] ?? lang?.[2] ?? lang?.[3])?.toLowerCase()
277
- if (!name || !TS_LANGS.has(name)) return null
401
+ const marker = typescriptAttr(attrs)
402
+ if (!marker) return null
278
403
 
279
- let rest = attrs.replace(LANG_ATTR_RE, "").trimEnd()
404
+ let rest = attrs.replace(marker, "").trimEnd()
280
405
  const setup = rest.match(SETUP_ATTR_RE)
281
406
  if (setup) {
282
407
  const signature = await stripSignatureTypes(setup[2] ?? setup[3], `${file}.signature.ts`)
@@ -324,7 +449,9 @@ const compileScriptBlocks = async (source: string, file: string): Promise<string
324
449
  // component's markers. An instance only used as a definition has nothing to
325
450
  // re-render (nested clones can't be reached from this module), so it falls
326
451
  // back to a full reload.
327
- const componentModule = (source: string, include: RegExp, filename: string): string => {
452
+ // `precompiled` is the precompiled script's URL, as a JS expression - present
453
+ // only in safeEval mode
454
+ const componentModule = (source: string, include: RegExp, filename: string, precompiled?: string): string => {
328
455
  const hoisted = hoistableImports(source, include)
329
456
  const imports = hoisted
330
457
  .map((spec, i) =>
@@ -335,9 +462,13 @@ const componentModule = (source: string, include: RegExp, filename: string): str
335
462
  .join("\n")
336
463
  const modulesMap = `{ ${hoisted.map((spec, i) => `${JSON.stringify(spec)}: __jq79_${i}`).join(", ")} }`
337
464
 
465
+ // without safeEval, the module is exactly what it always was
466
+ const safeEvalImport = precompiled ? `\nimport { safeEval } from "${SAFE_EVAL_ID}"` : ""
467
+ const safeEvalCall = precompiled ? `\nawait safeEval(Component79, ${precompiled})` : ""
468
+
338
469
  return `
339
- import { Component79 } from "jq79"
340
- ${imports}
470
+ import { Component79 } from "jq79"${safeEvalImport}
471
+ ${imports}${safeEvalCall}
341
472
 
342
473
  const src = ${JSON.stringify(source)}
343
474
  const modules = ${modulesMap}
@@ -371,6 +502,11 @@ ${declaredComponents(source).map(name => `export const ${name} = component.${nam
371
502
  export function jq79(options: Jq79PluginOptions = {}): Plugin {
372
503
  const include = options.include ?? /\.html$/
373
504
  const { exclude } = options
505
+ const safeEval = safeEvalOptions(options.safeEval)
506
+ // dev only: each component's precompiled script, by the id its module asks
507
+ // for - so the server hands out what the plugin compiled and nothing else
508
+ const devScripts = new Map<string, string>()
509
+ const devIds = new Map<string, number>()
374
510
 
375
511
  let config: ResolvedConfig | null = null
376
512
 
@@ -382,7 +518,21 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
382
518
  config = resolved
383
519
  },
384
520
 
521
+ configureServer(server) {
522
+ if (!safeEval) return
523
+ server.middlewares.use((req, res, next) => {
524
+ const url = new URL(req.url ?? "/", "http://localhost")
525
+ if (!url.pathname.endsWith(PRECOMPILED_PATH)) return next()
526
+ const script = devScripts.get(url.searchParams.get("id") ?? "")
527
+ if (script === undefined) return next()
528
+ res.setHeader("Content-Type", "text/javascript")
529
+ res.setHeader("Cache-Control", "no-cache")
530
+ res.end(script)
531
+ })
532
+ },
533
+
385
534
  async resolveId(source, importer) {
535
+ if (source === SAFE_EVAL_ID) return RESOLVED_SAFE_EVAL_ID
386
536
  if (!importer) return null // entry points are never components
387
537
  if (source.includes("?")) return null // ?raw, ?url, ... keep their meaning
388
538
  if (!include.test(source)) return null
@@ -394,6 +544,8 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
394
544
  },
395
545
 
396
546
  async load(id) {
547
+ // a Vite page's functions come from the build, so it never registers the worker
548
+ if (id === RESOLVED_SAFE_EVAL_ID) return safeEvalModule({ ...safeEval, worker: false })
397
549
  if (!id.endsWith(COMPONENT_QUERY)) return null
398
550
  const file = id.slice(0, -COMPONENT_QUERY.length)
399
551
 
@@ -405,7 +557,23 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
405
557
  // shows a path the user recognizes instead of an anonymous VM script
406
558
  const filename = config ? relative(config.root, file) : file
407
559
 
408
- return { code: componentModule(source, include, filename), map: null }
560
+ if (!safeEval) return { code: componentModule(source, include, filename), map: null }
561
+
562
+ // the component's functions, compiled after its TypeScript and styles are:
563
+ // the runtime compiles what reaches it, and this is what reaches it
564
+ const script = componentScript(source, message => this.warn(`jq79: ${filename}: ${message}`))
565
+ let url: string
566
+ if (config?.command === "serve") {
567
+ let devId = devIds.get(file)
568
+ if (devId === undefined) devIds.set(file, devId = devIds.size)
569
+ devScripts.set(String(devId), script)
570
+ // versioned, so an edit loads the new functions rather than a cached copy
571
+ url = JSON.stringify(`${config.base}${PRECOMPILED_PATH.slice(1)}?id=${devId}&v=${Date.now()}`)
572
+ } else {
573
+ const ref = this.emitFile({ type: "asset", name: `${basename(file)}.jq79.js`, source: script })
574
+ url = `import.meta.ROLLUP_FILE_URL_${ref}`
575
+ }
576
+ return { code: componentModule(source, include, filename, url), map: null }
409
577
  },
410
578
  }
411
579
  }
package/dist/dom.d.ts CHANGED
@@ -1,7 +1,25 @@
1
- export declare function $(selector: string): Element | null;
2
- export declare function $(el: Element, selector: string): Element | null;
3
- export declare function $$(selector: string): Element[];
4
- export declare function $$(el: Element, selector: string): Element[];
1
+ export type QueryOne = {
2
+ <K extends keyof HTMLElementTagNameMap>(selector: K): HTMLElementTagNameMap[K] | null;
3
+ <K extends keyof SVGElementTagNameMap>(selector: K): SVGElementTagNameMap[K] | null;
4
+ <E extends Element = HTMLElement>(selector: string): E | null;
5
+ };
6
+ export type QueryAll = {
7
+ <K extends keyof HTMLElementTagNameMap>(selector: K): HTMLElementTagNameMap[K][];
8
+ <K extends keyof SVGElementTagNameMap>(selector: K): SVGElementTagNameMap[K][];
9
+ <E extends Element = HTMLElement>(selector: string): E[];
10
+ };
11
+ export declare function $<K extends keyof HTMLElementTagNameMap>(selector: K): HTMLElementTagNameMap[K] | null;
12
+ export declare function $<K extends keyof SVGElementTagNameMap>(selector: K): SVGElementTagNameMap[K] | null;
13
+ export declare function $<E extends Element = HTMLElement>(selector: string): E | null;
14
+ export declare function $<K extends keyof HTMLElementTagNameMap>(el: Element, selector: K): HTMLElementTagNameMap[K] | null;
15
+ export declare function $<K extends keyof SVGElementTagNameMap>(el: Element, selector: K): SVGElementTagNameMap[K] | null;
16
+ export declare function $<E extends Element = HTMLElement>(el: Element, selector: string): E | null;
17
+ export declare function $$<K extends keyof HTMLElementTagNameMap>(selector: K): HTMLElementTagNameMap[K][];
18
+ export declare function $$<K extends keyof SVGElementTagNameMap>(selector: K): SVGElementTagNameMap[K][];
19
+ export declare function $$<E extends Element = HTMLElement>(selector: string): E[];
20
+ export declare function $$<K extends keyof HTMLElementTagNameMap>(el: Element, selector: K): HTMLElementTagNameMap[K][];
21
+ export declare function $$<K extends keyof SVGElementTagNameMap>(el: Element, selector: K): SVGElementTagNameMap[K][];
22
+ export declare function $$<E extends Element = HTMLElement>(el: Element, selector: string): E[];
5
23
  export declare const $create: (tag: string, attrs?: Record<string, any>) => HTMLElement;
6
24
  export declare function isSafeUrl(value: string): boolean;
7
25
  export type AllowUrl = (url: URL, tag: string, attr: string) => boolean;
package/dist/html.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type HTMLElementNode = {
2
+ tag: string;
3
+ attrs: Record<string, string>;
4
+ children: HTMLNode[];
5
+ };
6
+ export type HTMLNode = HTMLElementNode | string;
7
+ export declare const CHARACTER_REFERENCE_RE: RegExp;
8
+ export declare const decodeReference: (text: string, whole: string, ref: string, offset: number, inAttribute: boolean) => string;
9
+ export declare const parseHTML: (input: string) => HTMLNode[];