jq79 0.7.0 → 0.7.2

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,12 @@ 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, a block that says it is
16
+ // written in something else: <style lang="scss"> (or less/stylus/sass) is
17
+ // 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
19
+ // component only works through the bundler; loaded with fetch() it would reach
20
+ // the runtime uncompiled, which the runtime warns about.
19
21
  //
20
22
  // Only .html files imported from other modules are claimed; entry points
21
23
  // (index.html) have no importer and imports carrying an explicit query
@@ -32,7 +34,10 @@ export interface Jq79PluginOptions {
32
34
  // Vite's own html handling (entries, asset pipeline) leaves them alone
33
35
  const COMPONENT_QUERY = "?jq79"
34
36
 
35
- const SCRIPT_BLOCK_RE = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi
37
+ // a <script> block with its attribute string, so `lang` can be read and the
38
+ // body replaced - the same shape as STYLE_BLOCK_RE below, quote-aware so a
39
+ // ">" inside an attribute value (`:setup="{ n = a > 1 }"`) doesn't end the tag
40
+ const SCRIPT_BLOCK_RE = /<script((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/script\s*>/gi
36
41
  // import("...") with a literal specifier, tried at word boundaries the
37
42
  // scanner below reaches (which is what skips $__import and foo.import(...))
38
43
  const IMPORT_CALL_RE = /import\s*\(\s*(["'])([^"'\n]+?)\1\s*\)/y
@@ -95,7 +100,7 @@ const isExternalUrl = (spec: string) => /^[a-z][a-z0-9+.-]*:/i.test(spec) || spe
95
100
  // are .html specifiers the plugin wouldn't claim as components
96
101
  const hoistableImports = (source: string, include: RegExp): string[] => {
97
102
  const specifiers = new Set<string>()
98
- for (const [, script] of source.matchAll(SCRIPT_BLOCK_RE)) {
103
+ for (const [, , script] of source.matchAll(SCRIPT_BLOCK_RE)) {
99
104
  for (const spec of importSpecifiers(script)) {
100
105
  if (isExternalUrl(spec)) continue
101
106
  if (isHtmlUrl(spec) && !include.test(spec)) continue // html left to runtime fetch
@@ -148,6 +153,14 @@ const declaredComponents = (source: string): string[] => {
148
153
  // inside one doesn't end the tag early
149
154
  const STYLE_BLOCK_RE = /<style((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/style\s*>/gi
150
155
  const LANG_ATTR_RE = /\blang\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
156
+ const TYPE_ATTR_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
157
+
158
+ // one attribute's value out of a tag's attribute string, whichever way it was
159
+ // quoted - null when the attribute isn't there at all
160
+ const attrValue = (attrs: string, re: RegExp): string | null => {
161
+ const found = attrs.match(re)
162
+ return found ? found[1] ?? found[2] ?? found[3] : null
163
+ }
151
164
 
152
165
  // compiles <style lang="scss|less|styl|sass"> blocks to plain CSS with Vite's
153
166
  // own preprocessing (the same call @vitejs/plugin-vue makes), so the runtime
@@ -166,9 +179,8 @@ const compileStyleBlocks = async (
166
179
  const blocks = [...source.matchAll(STYLE_BLOCK_RE)]
167
180
  const compiled = await Promise.all(
168
181
  blocks.map(async ([, attrs, content]) => {
169
- const lang = attrs.match(LANG_ATTR_RE)
170
- if (!lang) return null
171
- const extension = lang[1] ?? lang[2] ?? lang[3]
182
+ const extension = attrValue(attrs, LANG_ATTR_RE)
183
+ if (extension === null) return null
172
184
  const result = await preprocessCSS(content, `${file}.${extension}`, config)
173
185
  result.deps?.forEach(addWatchFile)
174
186
  return { attrs: attrs.replace(LANG_ATTR_RE, "").trimEnd(), css: result.code }
@@ -186,6 +198,134 @@ const compileStyleBlocks = async (
186
198
  return out + source.slice(last)
187
199
  }
188
200
 
201
+ // languages a <script lang> is compiled from. Anything else is left as written
202
+ // for the runtime to warn about, rather than guessed at
203
+ 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
208
+ // spelling of the same two media types
209
+ const TS_TYPE_RE = /^(?:text|application)\/(?:x-)?typescript$/
210
+
211
+ // what marks a script block as TypeScript, given back as the attribute to drop
212
+ // from the emitted tag - the block is compiled to JS, so the mark goes with the
213
+ // types whichever one carried it. A `lang` answers on its own: `lang="coffee"`
214
+ // is a language this plugin doesn't compile, and reading `type` past it would
215
+ // compile a block the author said was something else
216
+ const typescriptAttr = (attrs: string): RegExp | null => {
217
+ const lang = attrValue(attrs, LANG_ATTR_RE)
218
+ if (lang !== null) return TS_LANGS.has(lang.trim().toLowerCase()) ? LANG_ATTR_RE : null
219
+ const type = attrValue(attrs, TYPE_ATTR_RE)
220
+ return type !== null && TS_TYPE_RE.test(type.trim().toLowerCase()) ? TYPE_ATTR_RE : null
221
+ }
222
+
223
+ type ViteTransform = (code: string, id: string, options?: unknown) => Promise<{ code: string }>
224
+
225
+ // what strips the types: vite's own transform, so the plugin carries no
226
+ // compiler of its own. *Which* transform is a version question, and neither
227
+ // answer covers the peer range (vite >= 5) alone - transformWithOxc is the one
228
+ // vite is moving to but only exists from vite 7, while transformWithEsbuild is
229
+ // deprecated under vite 8 and throws there unless esbuild is installed
230
+ // separately. So it is looked up at call time: oxc where it exists, esbuild on
231
+ // the older versions that ship it.
232
+ //
233
+ // Both drop unused value imports by default, because a TS transform can't tell
234
+ // a type-only import from an unused one. That would be silent damage here: a
235
+ // factory script's `import Row from "./row.html"` would vanish, taking with it
236
+ // the specifier hoistableImports needs to pull the child into the bundle. The
237
+ // flags below are what keep it - only `import type` is erased
238
+ const stripTypes = async (ts: string, file: string): Promise<string> => {
239
+ const vite = (await import("vite")) as unknown as { transformWithOxc?: ViteTransform }
240
+ const { code } = vite.transformWithOxc
241
+ ? await vite.transformWithOxc(ts, file, { lang: "ts", typescript: { onlyRemoveTypeImports: true } })
242
+ : await transformWithEsbuild(ts, file, {
243
+ loader: "ts",
244
+ tsconfigRaw: { compilerOptions: { verbatimModuleSyntax: true } },
245
+ })
246
+ // a script whose only imports were `import type` comes back marked as a
247
+ // module. `export {}` exports nothing, and it is a SyntaxError inside the
248
+ // Function body the runtime compiles a script into
249
+ return code.replace(/^[ \t]*export\s*\{\s*\}\s*;?[ \t]*$/m, "")
250
+ }
251
+
252
+ // the `:setup` attribute, when it carries a value (a bare `:setup` is the
253
+ // closed signature and can't be typed)
254
+ const SETUP_ATTR_RE = /(:setup\s*=\s*)(?:"([^"]*)"|'([^']*)')/i
255
+ // the arrow body the signature is wrapped in, so the parameter list can be
256
+ // found again in the output without matching brackets
257
+ const SIGNATURE_MARKER = "__jq79_signature__"
258
+
259
+ // a component's props signature lives in the `:setup` *attribute*, not in the
260
+ // script body, so the body's transform never sees it - and the TypeScript mark
261
+ // has to mean the same thing on both halves of the block, or a typed signature
262
+ // either survives into a component the plugin just promised was JS or, for
263
+ // `_: Props`, stops reading as a signature at all.
264
+ //
265
+ // It goes through the same transform as everything else, wrapped as a
266
+ // parameter list, rather than being cut with a scanner of its own: the
267
+ // annotation can sit on the pattern (`{ a }: Props`), on the permissive `_`, or
268
+ // inside a default (`{ step = 1 as number }`, which the runtime would evaluate
269
+ // and silently drop), and telling those apart is a parser's job. Both transforms
270
+ // hand back `(<params>) => <marker>` on one line, parens intact
271
+ const stripSignatureTypes = async (value: string, file: string): Promise<string> => {
272
+ const compiled = await stripTypes(`(${value}) => ${SIGNATURE_MARKER}`, file)
273
+ const marker = compiled.indexOf(SIGNATURE_MARKER)
274
+ if (marker === -1) return value // not the shape expected: leave it as written
275
+ const head = compiled.slice(0, marker).trimEnd()
276
+ const params = (head.endsWith("=>") ? head.slice(0, -2) : head).trim()
277
+ return params.startsWith("(") && params.endsWith(")") ? params.slice(1, -1).trim() : params
278
+ }
279
+
280
+ // the signature back into a double-quoted attribute. Only `"` needs escaping:
281
+ // the transforms normalize string literals to double quotes, so a default the
282
+ // author wrote as `'x'` comes back as `"x"` and would end the attribute early.
283
+ // `&` is deliberately left alone - `&&` in a default is not an entity and
284
+ // survives the parse, while escaping it would double-encode a source that
285
+ // already wrote `&quot;`
286
+ const quoteAttrValue = (value: string) => value.replace(/"/g, "&quot;")
287
+
288
+ // compiles TypeScript script blocks to plain JS, so the runtime only ever sees
289
+ // JS - the same deal <style lang="scss"> gets, for a sharper reason. The setup
290
+ // scanner is not a parser: `let count: number = 0` reaching it is not a syntax
291
+ // error but a labeled statement that assigns to `number`, so it runs and leaves
292
+ // `count` undeclared. Whichever attribute marked the block (`lang="ts"` or
293
+ // `type="text/typescript"`) is dropped from the emitted tag and every other one
294
+ // (`:setup`, `:mounted`) is left as written.
295
+ //
296
+ // This runs before hoistableImports reads the source, so an `import type`
297
+ // specifier is already gone by the time the plugin decides what to bundle
298
+ const compileScriptBlocks = async (source: string, file: string): Promise<string> => {
299
+ const blocks = [...source.matchAll(SCRIPT_BLOCK_RE)]
300
+ const compiled = await Promise.all(
301
+ blocks.map(async ([, attrs, content]) => {
302
+ const marker = typescriptAttr(attrs)
303
+ if (!marker) return null
304
+
305
+ let rest = attrs.replace(marker, "").trimEnd()
306
+ const setup = rest.match(SETUP_ATTR_RE)
307
+ if (setup) {
308
+ const signature = await stripSignatureTypes(setup[2] ?? setup[3], `${file}.signature.ts`)
309
+ // a function replacement, not a string: `$&` and friends are live in a
310
+ // replacement string and a default value is arbitrary source
311
+ rest = rest.replace(SETUP_ATTR_RE, () => `${setup[1]}"${quoteAttrValue(signature)}"`)
312
+ }
313
+
314
+ return { attrs: rest, js: await stripTypes(content, `${file}.ts`) }
315
+ })
316
+ )
317
+
318
+ let out = ""
319
+ let last = 0
320
+ blocks.forEach((block, i) => {
321
+ const done = compiled[i]
322
+ if (!done) return
323
+ out += source.slice(last, block.index) + `<script${done.attrs}>${done.js}</script>`
324
+ last = block.index + block[0].length
325
+ })
326
+ return out + source.slice(last)
327
+ }
328
+
189
329
  // the emitted module. Literal import("...") specifiers found in the
190
330
  // component's scripts become real module imports, handed to Component79 as a
191
331
  // resolution map: at runtime $__import checks the map before falling back to
@@ -284,6 +424,7 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
284
424
  const file = id.slice(0, -COMPONENT_QUERY.length)
285
425
 
286
426
  let source = await readFile(file, "utf8")
427
+ source = await compileScriptBlocks(source, file)
287
428
  if (config) source = await compileStyleBlocks(source, file, config, dep => this.addWatchFile(dep))
288
429
 
289
430
  // the runtime names the component's setup scripts after this, so devtools