jq79 0.6.6 → 0.7.1

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
@@ -10,12 +10,36 @@
10
10
  [![esm](https://jgermade.github.io/jq79/badges/esm-size.svg)](#cdn)
11
11
  [![cdn](https://jgermade.github.io/jq79/badges/cjs-size.svg)](#cdn)
12
12
 
13
- A independent reactive component library that ships as a single file. Svelte-style reactive scripts, fine-grained DOM updates via proxy-based dependency tracking
13
+ A independent reactive component library that ships as a single file. Svelte-style reactive scripts, fine-grained DOM updates via proxy-based dependency tracking.
14
14
 
15
- > no compiler required, no virtual DOM, no dependencies.
15
+ **No compiler, no bundler, no dependencies.** A component is a `.html` file — the browser already knows how to fetch one. Drop the library from a CDN, serve your components from any static host, and the page works. No `npm install`, no build step, no config.
16
16
 
17
17
  **[Take the tutorial →](https://jgermade.github.io/jq79/tutorial/)** — a handful of exercises you edit in the browser, with a live preview. Because there's no compiler, the tutorial runs the real library: the component you write is the component that mounts.
18
18
 
19
+ ## No build step
20
+
21
+ A component is a `.html` file. The browser already knows how to fetch one — so
22
+ nothing has to happen to your components between writing them and serving them:
23
+
24
+ ```html
25
+ <!doctype html>
26
+ <div id="app"></div>
27
+
28
+ <script type="module">
29
+ import { C79 } from "https://esm.sh/jq79"
30
+
31
+ C79.fetch("./app.html").mount("#app", { title: "Today" })
32
+ </script>
33
+ ```
34
+
35
+ That is the whole deployment. The library from a CDN, the component from your
36
+ own host, no build step in between. `C79` is `Component79` under a shorter name
37
+ — the same class, the same API.
38
+
39
+ While you're writing them, `npx jq79 dev` serves that folder and hot-reloads the
40
+ components you edit, keeping their state — no build step there either. See the
41
+ [dev server](docs/dev-server.md).
42
+
19
43
  ## Installation
20
44
 
21
45
  ### npm
@@ -25,7 +49,7 @@ npm install jq79
25
49
  ```
26
50
 
27
51
  ```js
28
- import { Component79, $, $$ } from "jq79"
52
+ import { Component79, C79, $, $$, $reactive, $toRaw, parseComponent } from "jq79"
29
53
  ```
30
54
 
31
55
  ### Vite
@@ -73,11 +97,11 @@ Once published to npm, the package is automatically served by every major CDN
73
97
  Pin a version in production: `https://cdn.jsdelivr.net/npm/jq79@0.1.0/...` (the GitHub Pages copy always tracks the latest release).
74
98
 
75
99
  Which is enough for a whole page: the library from a CDN, the component from your
76
- own host, no build step in between. `Component79.fetch` (or `C79`, the same class
77
- under a shorter name) hands back a pending component you can mount right away:
100
+ own host, no build step in between. `C79` (short for `Component79`, the same class)
101
+ hands back a pending component you can mount right away:
78
102
 
79
103
  ```html
80
- <main></main>
104
+ <main id="app"></main>
81
105
 
82
106
  <script type="module">
83
107
  import { C79 } from "https://jgermade.github.io/jq79/jq79.js"
@@ -91,10 +115,31 @@ under a shorter name) hands back a pending component you can mount right away:
91
115
  `fetchAll([...])` fetches several at once instead. See
92
116
  [loading remote components](docs/components.md#loading-remote-components).
93
117
 
118
+ The library also exports `parseComponent(source)` as a shorthand for
119
+ `new Component79(source)`, and `enableHotReload()` / `hotUpdate(filename, src)`
120
+ for custom dev setups — see the [dev server](docs/dev-server.md).
121
+
94
122
  The source is small enough to read in a sitting: the core (parsing, rendering, components) lives in [`src/jq79.ts`](src/jq79.ts), with three leaf helpers — [`dom.ts`](src/dom.ts), [`reactive.ts`](src/reactive.ts) and [`transform.ts`](src/transform.ts). The published build is a single dependency-free file.
95
123
 
96
124
  ## Quick start
97
125
 
126
+ ### From a CDN (no build step)
127
+
128
+ ```html
129
+ <!doctype html>
130
+ <div id="app"></div>
131
+
132
+ <script type="module">
133
+ import { C79 } from "https://esm.sh/jq79"
134
+
135
+ C79.fetch("./app.html").mount("#app", { title: "Today" })
136
+ </script>
137
+ ```
138
+
139
+ The component file is served as-is from your host — no bundler, no config.
140
+
141
+ ### From npm
142
+
98
143
  ```js
99
144
  import { Component79 } from "jq79"
100
145
 
@@ -0,0 +1,74 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600" width="600" height="600" role="img" aria-label="Four flat-style portraits on coloured quadrants with a lowercase wordmark">
2
+ <title>Four-quadrant pop portraits</title>
3
+
4
+ <defs>
5
+ <!-- shared facial features: eyes, brows, nose, mouth -->
6
+ <g id="features">
7
+ <path d="M112 128q14-9 30-2" fill="none" stroke="#1a1a1a" stroke-width="5" stroke-linecap="round"/>
8
+ <path d="M158 126q16-7 30 2" fill="none" stroke="#1a1a1a" stroke-width="5" stroke-linecap="round"/>
9
+ <ellipse cx="127" cy="146" rx="7" ry="8" fill="#1a1a1a"/>
10
+ <ellipse cx="173" cy="146" rx="7" ry="8" fill="#1a1a1a"/>
11
+ <path d="M150 150v26q0 6-8 8" fill="none" stroke="#1a1a1a" stroke-width="4" stroke-linecap="round"/>
12
+ <path d="M132 199q18 9 36 0" fill="none" stroke="#1a1a1a" stroke-width="5" stroke-linecap="round"/>
13
+ </g>
14
+
15
+ <!-- head, neck and outline, reused by every portrait -->
16
+ <g id="head">
17
+ <rect x="134" y="196" width="32" height="42" rx="14" fill="#e8bd97"/>
18
+ <ellipse cx="150" cy="142" rx="62" ry="76" fill="#f2cdaa" stroke="#1a1a1a" stroke-width="3"/>
19
+ <path d="M88 150q-9 12 0 22" fill="#f2cdaa" stroke="#1a1a1a" stroke-width="3"/>
20
+ <path d="M212 150q9 12 0 22" fill="#f2cdaa" stroke="#1a1a1a" stroke-width="3"/>
21
+ </g>
22
+ </defs>
23
+
24
+ <!-- ── top-left ─────────────────────────────────────────── -->
25
+ <g>
26
+ <rect width="300" height="300" fill="#1f5fa8"/>
27
+ <path d="M34 300q6-58 62-72l54-12 54 12q56 14 62 72z" fill="#2b2b33" stroke="#1a1a1a" stroke-width="3"/>
28
+ <use href="#head"/>
29
+ <path d="M88 142a62 76 0 0 1 124 0L212 116C200 94 180 86 150 90 120 94 100 98 88 118Z" fill="#3a2a1c" stroke="#1a1a1a" stroke-width="3"/>
30
+ <use href="#features"/>
31
+ <g fill="none" stroke="#1a1a1a" stroke-width="5">
32
+ <circle cx="127" cy="146" r="21"/>
33
+ <circle cx="173" cy="146" r="21"/>
34
+ <path d="M148 144h4M106 140l-16-4M194 140l16-4"/>
35
+ </g>
36
+ </g>
37
+
38
+ <!-- ── top-right ────────────────────────────────────────── -->
39
+ <g transform="translate(300 0)">
40
+ <rect width="300" height="300" fill="#c8202e"/>
41
+ <path d="M34 300q6-58 62-72l54-12 54 12q56 14 62 72z" fill="#1e1e24" stroke="#1a1a1a" stroke-width="3"/>
42
+ <use href="#head"/>
43
+ <path d="M88 142a62 76 0 0 1 124 0l2 26C200 132 178 128 150 126 122 124 100 132 86 168Z" fill="#16110e" stroke="#1a1a1a" stroke-width="3"/>
44
+ <use href="#features"/>
45
+ </g>
46
+
47
+ <!-- ── bottom-left ──────────────────────────────────────── -->
48
+ <g transform="translate(0 300)">
49
+ <rect width="300" height="300" fill="#2f8a4c"/>
50
+ <path d="M34 300q6-58 62-72l54-12 54 12q56 14 62 72z" fill="#dcdcd6" stroke="#1a1a1a" stroke-width="3"/>
51
+ <use href="#head"/>
52
+ <path d="M88 142a62 76 0 0 1 124 0l0-26-18 10-6-16-16 14-14-16-16 14-14-12-16 12-8-8Z" fill="#c98a3c" stroke="#1a1a1a" stroke-width="3" stroke-linejoin="round"/>
53
+ <use href="#features"/>
54
+ </g>
55
+
56
+ <!-- ── bottom-right ─────────────────────────────────────── -->
57
+ <g transform="translate(300 300)">
58
+ <rect width="300" height="300" fill="#f2c318"/>
59
+ <path d="M34 300q6-58 62-72l54-12 54 12q56 14 62 72z" fill="#2f4d8a" stroke="#1a1a1a" stroke-width="3"/>
60
+ <use href="#head"/>
61
+ <path d="M88 142a62 76 0 0 1 124 0l-2-30C196 96 176 88 150 90 124 92 102 96 90 118Z" fill="#8a6a3c" stroke="#1a1a1a" stroke-width="3"/>
62
+ <use href="#features"/>
63
+ <g fill="none" stroke="#1a1a1a" stroke-width="5">
64
+ <rect x="106" y="130" width="42" height="32" rx="6"/>
65
+ <rect x="152" y="130" width="42" height="32" rx="6"/>
66
+ <path d="M148 144h4M106 138l-16-4M194 138l16-4"/>
67
+ </g>
68
+ </g>
69
+
70
+ <!-- wordmark -->
71
+ <text x="300" y="332" text-anchor="middle"
72
+ font-family="'Helvetica Neue', Helvetica, Arial, sans-serif"
73
+ font-size="92" font-weight="700" letter-spacing="-2" fill="#ffffff">blur</text>
74
+ </svg>
package/dev/vite.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises"
2
2
  import { relative } from "node:path"
3
- import { preprocessCSS } from "vite"
3
+ import { preprocessCSS, transformWithEsbuild } from "vite"
4
4
  import type { Plugin, ResolvedConfig } from "vite"
5
5
 
6
6
  // Vite plugin: import .html single-file components as modules.
@@ -12,10 +12,11 @@ import type { Plugin, ResolvedConfig } from "vite"
12
12
  // thing `await Component79.fetch(url)` resolves to, but bundled at build time
13
13
  // instead of fetched at runtime. The component source is inlined verbatim, so
14
14
  // a file keeps working unchanged if it's ever served from public/ and loaded
15
- // with fetch instead - with one deliberate exception: <style lang="scss"> (or
16
- // less/stylus/sass) is compiled to plain CSS here. A component using `lang`
17
- // therefore only works through the bundler; loaded with fetch() it would
18
- // reach the runtime uncompiled, which the runtime warns about.
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
20
  //
20
21
  // Only .html files imported from other modules are claimed; entry points
21
22
  // (index.html) have no importer and imports carrying an explicit query
@@ -32,7 +33,10 @@ export interface Jq79PluginOptions {
32
33
  // Vite's own html handling (entries, asset pipeline) leaves them alone
33
34
  const COMPONENT_QUERY = "?jq79"
34
35
 
35
- const SCRIPT_BLOCK_RE = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi
36
+ // a <script> block with its attribute string, so `lang` can be read and the
37
+ // body replaced - the same shape as STYLE_BLOCK_RE below, quote-aware so a
38
+ // ">" inside an attribute value (`:setup="{ n = a > 1 }"`) doesn't end the tag
39
+ const SCRIPT_BLOCK_RE = /<script((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/script\s*>/gi
36
40
  // import("...") with a literal specifier, tried at word boundaries the
37
41
  // scanner below reaches (which is what skips $__import and foo.import(...))
38
42
  const IMPORT_CALL_RE = /import\s*\(\s*(["'])([^"'\n]+?)\1\s*\)/y
@@ -95,7 +99,7 @@ const isExternalUrl = (spec: string) => /^[a-z][a-z0-9+.-]*:/i.test(spec) || spe
95
99
  // are .html specifiers the plugin wouldn't claim as components
96
100
  const hoistableImports = (source: string, include: RegExp): string[] => {
97
101
  const specifiers = new Set<string>()
98
- for (const [, script] of source.matchAll(SCRIPT_BLOCK_RE)) {
102
+ for (const [, , script] of source.matchAll(SCRIPT_BLOCK_RE)) {
99
103
  for (const spec of importSpecifiers(script)) {
100
104
  if (isExternalUrl(spec)) continue
101
105
  if (isHtmlUrl(spec) && !include.test(spec)) continue // html left to runtime fetch
@@ -186,6 +190,116 @@ const compileStyleBlocks = async (
186
190
  return out + source.slice(last)
187
191
  }
188
192
 
193
+ // languages a <script lang> is compiled from. Anything else is left as written
194
+ // for the runtime to warn about, rather than guessed at
195
+ const TS_LANGS = new Set(["ts", "typescript"])
196
+
197
+ type ViteTransform = (code: string, id: string, options?: unknown) => Promise<{ code: string }>
198
+
199
+ // what strips the types: vite's own transform, so the plugin carries no
200
+ // compiler of its own. *Which* transform is a version question, and neither
201
+ // answer covers the peer range (vite >= 5) alone - transformWithOxc is the one
202
+ // vite is moving to but only exists from vite 7, while transformWithEsbuild is
203
+ // deprecated under vite 8 and throws there unless esbuild is installed
204
+ // separately. So it is looked up at call time: oxc where it exists, esbuild on
205
+ // the older versions that ship it.
206
+ //
207
+ // Both drop unused value imports by default, because a TS transform can't tell
208
+ // a type-only import from an unused one. That would be silent damage here: a
209
+ // factory script's `import Row from "./row.html"` would vanish, taking with it
210
+ // the specifier hoistableImports needs to pull the child into the bundle. The
211
+ // flags below are what keep it - only `import type` is erased
212
+ const stripTypes = async (ts: string, file: string): Promise<string> => {
213
+ const vite = (await import("vite")) as unknown as { transformWithOxc?: ViteTransform }
214
+ const { code } = vite.transformWithOxc
215
+ ? await vite.transformWithOxc(ts, file, { lang: "ts", typescript: { onlyRemoveTypeImports: true } })
216
+ : await transformWithEsbuild(ts, file, {
217
+ loader: "ts",
218
+ tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
219
+ })
220
+ // a script whose only imports were `import type` comes back marked as a
221
+ // module. `export {}` exports nothing, and it is a SyntaxError inside the
222
+ // Function body the runtime compiles a script into
223
+ return code.replace(/^[ \t]*export\s*\{\s*\}\s*;?[ \t]*$/m, "")
224
+ }
225
+
226
+ // the `:setup` attribute, when it carries a value (a bare `:setup` is the
227
+ // closed signature and can't be typed)
228
+ const SETUP_ATTR_RE = /(:setup\s*=\s*)(?:"([^"]*)"|'([^']*)')/i
229
+ // the arrow body the signature is wrapped in, so the parameter list can be
230
+ // found again in the output without matching brackets
231
+ const SIGNATURE_MARKER = "__jq79_signature__"
232
+
233
+ // 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.
238
+ //
239
+ // It goes through the same transform as everything else, wrapped as a
240
+ // parameter list, rather than being cut with a scanner of its own: the
241
+ // annotation can sit on the pattern (`{ a }: Props`), on the permissive `_`, or
242
+ // inside a default (`{ step = 1 as number }`, which the runtime would evaluate
243
+ // and silently drop), and telling those apart is a parser's job. Both transforms
244
+ // hand back `(<params>) => <marker>` on one line, parens intact
245
+ const stripSignatureTypes = async (value: string, file: string): Promise<string> => {
246
+ const compiled = await stripTypes(`(${value}) => ${SIGNATURE_MARKER}`, file)
247
+ const marker = compiled.indexOf(SIGNATURE_MARKER)
248
+ if (marker === -1) return value // not the shape expected: leave it as written
249
+ const head = compiled.slice(0, marker).trimEnd()
250
+ const params = (head.endsWith("=>") ? head.slice(0, -2) : head).trim()
251
+ return params.startsWith("(") && params.endsWith(")") ? params.slice(1, -1).trim() : params
252
+ }
253
+
254
+ // the signature back into a double-quoted attribute. Only `"` needs escaping:
255
+ // the transforms normalize string literals to double quotes, so a default the
256
+ // author wrote as `'x'` comes back as `"x"` and would end the attribute early.
257
+ // `&` is deliberately left alone - `&&` in a default is not an entity and
258
+ // survives the parse, while escaping it would double-encode a source that
259
+ // already wrote `&quot;`
260
+ const quoteAttrValue = (value: string) => value.replace(/"/g, "&quot;")
261
+
262
+ // compiles <script lang="ts"> blocks to plain JS, so the runtime only ever sees
263
+ // JS - the same deal <style lang="scss"> gets, for a sharper reason. The setup
264
+ // scanner is not a parser: `let count: number = 0` reaching it is not a syntax
265
+ // 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.
268
+ //
269
+ // This runs before hoistableImports reads the source, so an `import type`
270
+ // specifier is already gone by the time the plugin decides what to bundle
271
+ const compileScriptBlocks = async (source: string, file: string): Promise<string> => {
272
+ const blocks = [...source.matchAll(SCRIPT_BLOCK_RE)]
273
+ const compiled = await Promise.all(
274
+ 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
278
+
279
+ let rest = attrs.replace(LANG_ATTR_RE, "").trimEnd()
280
+ const setup = rest.match(SETUP_ATTR_RE)
281
+ if (setup) {
282
+ const signature = await stripSignatureTypes(setup[2] ?? setup[3], `${file}.signature.ts`)
283
+ // a function replacement, not a string: `$&` and friends are live in a
284
+ // replacement string and a default value is arbitrary source
285
+ rest = rest.replace(SETUP_ATTR_RE, () => `${setup[1]}"${quoteAttrValue(signature)}"`)
286
+ }
287
+
288
+ return { attrs: rest, js: await stripTypes(content, `${file}.ts`) }
289
+ })
290
+ )
291
+
292
+ let out = ""
293
+ let last = 0
294
+ blocks.forEach((block, i) => {
295
+ const done = compiled[i]
296
+ if (!done) return
297
+ out += source.slice(last, block.index) + `<script${done.attrs}>${done.js}</script>`
298
+ last = block.index + block[0].length
299
+ })
300
+ return out + source.slice(last)
301
+ }
302
+
189
303
  // the emitted module. Literal import("...") specifiers found in the
190
304
  // component's scripts become real module imports, handed to Component79 as a
191
305
  // resolution map: at runtime $__import checks the map before falling back to
@@ -284,6 +398,7 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
284
398
  const file = id.slice(0, -COMPONENT_QUERY.length)
285
399
 
286
400
  let source = await readFile(file, "utf8")
401
+ source = await compileScriptBlocks(source, file)
287
402
  if (config) source = await compileStyleBlocks(source, file, config, dep => this.addWatchFile(dep))
288
403
 
289
404
  // the runtime names the component's setup scripts after this, so devtools