jq79 0.7.2 → 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
  //
@@ -15,7 +19,7 @@ import type { Plugin, ResolvedConfig } from "vite"
15
19
  // with fetch instead - with one deliberate exception, a block that says it is
16
20
  // written in something else: <style lang="scss"> (or less/stylus/sass) is
17
21
  // compiled to plain CSS here, and a TypeScript script - <script lang="ts">, or
18
- // the <script type="text/typescript"> editors read as one - to plain JS. Such a
22
+ // the same mark spelled <script type="text/typescript"> - to plain JS. Such a
19
23
  // component only works through the bundler; loaded with fetch() it would reach
20
24
  // the runtime uncompiled, which the runtime warns about.
21
25
  //
@@ -28,12 +32,105 @@ export interface Jq79PluginOptions {
28
32
  include?: RegExp
29
33
  // resolved absolute paths to skip even when `include` matches
30
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 }
31
43
  }
32
44
 
33
45
  // claimed modules get this suffix so their id no longer ends in ".html" and
34
46
  // Vite's own html handling (entries, asset pipeline) leaves them alone
35
47
  const COMPONENT_QUERY = "?jq79"
36
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
+
37
134
  // a <script> block with its attribute string, so `lang` can be read and the
38
135
  // body replaced - the same shape as STYLE_BLOCK_RE below, quote-aware so a
39
136
  // ">" inside an attribute value (`:setup="{ n = a > 1 }"`) doesn't end the tag
@@ -201,10 +298,12 @@ const compileStyleBlocks = async (
201
298
  // languages a <script lang> is compiled from. Anything else is left as written
202
299
  // for the runtime to warn about, rather than guessed at
203
300
  const TS_LANGS = new Set(["ts", "typescript"])
204
- // the other spelling of the same mark, and the one editors read: a component is
205
- // a plain .html file, not an SFC, so nothing in an IDE knows what `lang` means
206
- // there - embedded-script tooling picks a language from `type`, and a typed
207
- // block without one is linted as JavaScript. The `x-` forms are the historical
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
208
307
  // spelling of the same two media types
209
308
  const TS_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/
210
309
 
@@ -350,7 +449,9 @@ const compileScriptBlocks = async (source: string, file: string): Promise<string
350
449
  // component's markers. An instance only used as a definition has nothing to
351
450
  // re-render (nested clones can't be reached from this module), so it falls
352
451
  // back to a full reload.
353
- 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 => {
354
455
  const hoisted = hoistableImports(source, include)
355
456
  const imports = hoisted
356
457
  .map((spec, i) =>
@@ -361,9 +462,13 @@ const componentModule = (source: string, include: RegExp, filename: string): str
361
462
  .join("\n")
362
463
  const modulesMap = `{ ${hoisted.map((spec, i) => `${JSON.stringify(spec)}: __jq79_${i}`).join(", ")} }`
363
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
+
364
469
  return `
365
- import { Component79 } from "jq79"
366
- ${imports}
470
+ import { Component79 } from "jq79"${safeEvalImport}
471
+ ${imports}${safeEvalCall}
367
472
 
368
473
  const src = ${JSON.stringify(source)}
369
474
  const modules = ${modulesMap}
@@ -397,6 +502,11 @@ ${declaredComponents(source).map(name => `export const ${name} = component.${nam
397
502
  export function jq79(options: Jq79PluginOptions = {}): Plugin {
398
503
  const include = options.include ?? /\.html$/
399
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>()
400
510
 
401
511
  let config: ResolvedConfig | null = null
402
512
 
@@ -408,7 +518,21 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
408
518
  config = resolved
409
519
  },
410
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
+
411
534
  async resolveId(source, importer) {
535
+ if (source === SAFE_EVAL_ID) return RESOLVED_SAFE_EVAL_ID
412
536
  if (!importer) return null // entry points are never components
413
537
  if (source.includes("?")) return null // ?raw, ?url, ... keep their meaning
414
538
  if (!include.test(source)) return null
@@ -420,6 +544,8 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
420
544
  },
421
545
 
422
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 })
423
549
  if (!id.endsWith(COMPONENT_QUERY)) return null
424
550
  const file = id.slice(0, -COMPONENT_QUERY.length)
425
551
 
@@ -431,7 +557,23 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
431
557
  // shows a path the user recognizes instead of an anonymous VM script
432
558
  const filename = config ? relative(config.root, file) : file
433
559
 
434
- 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 }
435
577
  },
436
578
  }
437
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[];