jq79 0.3.22 → 0.3.23

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/src/reactive.ts CHANGED
@@ -41,9 +41,31 @@ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: stri
41
41
  const pathsOverlap = (a: string, b: string): boolean =>
42
42
  a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
43
43
 
44
+ // reads the raw object behind a store proxy. Module-level (not per-store) so a
45
+ // value that is already reactive - in this store or in another one - can be
46
+ // unwrapped before being wrapped again. Without it, handing the same object to
47
+ // two stores has each one wrapping the other's proxies, and since a wrap walks
48
+ // what it wraps, the nesting compounds until the process stops responding
49
+ const RAW = Symbol("jq79.raw")
50
+
51
+ const toRaw = <T>(value: T): T => {
52
+ let raw: any = value
53
+ while (raw !== null && typeof raw === "object" && raw[RAW]) raw = raw[RAW]
54
+ return raw
55
+ }
56
+
57
+ // marks a store's *root* proxy. A store put inside another store (a setup
58
+ // script's `const local = $reactive(...)`) has to pass through whole: it owns
59
+ // its listeners and its $on/$effect, so unwrapping it would strip away the very
60
+ // thing it is. Nested proxies carry no such marker and are unwrapped freely
61
+ const STORE = Symbol("jq79.store")
62
+
63
+ const isStore = (value: any): boolean =>
64
+ value !== null && typeof value === "object" && value[STORE] === true
65
+
44
66
  // active $effect() runs, innermost last - a module-level stack (rather than
45
67
  // one per store) so nested effects across stores still nest correctly; reads
46
- // during makeReactive's `get` trap are attributed to whichever run is on top
68
+ // during a proxy's `get` trap are attributed to whichever run is on top
47
69
  const trackerStack: Set<string>[] = []
48
70
 
49
71
  // runs fn with dependency tracking suspended - reads inside it are attributed
@@ -63,12 +85,22 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
63
85
  const exactListeners = new Map<string, Set<ChangeListener>>()
64
86
  const anyListeners = new Set<AnyChangeListener>()
65
87
  const effects = new Set<Effect>()
66
- // every proxy this store has ever handed out, so re-assigning an object
67
- // that's already reactive (e.g. `data.list = [data.list[1], data.list[0]]`)
68
- // doesn't wrap it a second time - which would hand out a *new* object
69
- // identity for the same logical item, breaking reference-equality checks
70
- // like :each's keyed diffing
71
- const reactiveProxies = new WeakSet<object>()
88
+
89
+ // one proxy per raw object, for this store alone. Keyed by the *raw object*
90
+ // rather than by its path, so identity travels with the object: :each diffs
91
+ // its items by reference (Object.is), and a reordered list has to hand back
92
+ // the same proxy for the same item or every row would re-render. The flip
93
+ // side is that an object's path is fixed when it is first wrapped, so after a
94
+ // reorder its notifications carry the old index - effects that read the list
95
+ // itself still wake up (pathsOverlap), which is what makes it a non-issue in
96
+ // practice
97
+ const proxies = new WeakMap<object, Record<string, any>>()
98
+
99
+ // $on/$onAny/$effect are served from the root proxy's `get` instead of being
100
+ // defined on the object: a store must leave nothing behind on the data it was
101
+ // handed, and two stores over one object would otherwise clobber each other's
102
+ // handles. Null-prototype, so `key in storeApi` can't match Object.prototype
103
+ const storeApi: Record<string, any> = Object.create(null)
72
104
 
73
105
  const notify = (dotKey: string, value: any, isNewKey = false) => {
74
106
  exactListeners.get(dotKey)?.forEach(listener => listener(value, dotKey))
@@ -81,21 +113,32 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
81
113
  })
82
114
  }
83
115
 
84
- const makeReactive = (obj: Record<string, any>, path: string): Record<string, any> => {
85
- if (reactiveProxies.has(obj)) return obj
116
+ const isWrappable = (value: any): value is Record<string, any> =>
117
+ value !== null && typeof value === "object" && isPlainData(value)
86
118
 
87
- Object.entries(obj).forEach(([key, value]) => {
88
- if (value && typeof value === "object" && isPlainData(value)) {
89
- obj[key] = makeReactive(value, path ? `${path}.${key}` : key)
90
- }
91
- })
119
+ // the reactive view of `raw`, created on demand. Callers must hand it a raw
120
+ // object (see toRaw at both call sites): wrapping a proxy is what compounds
121
+ const wrap = (raw: Record<string, any>, path: string): Record<string, any> => {
122
+ const cached = proxies.get(raw)
123
+ if (cached) return cached
92
124
 
93
- const proxy = new Proxy(obj, {
125
+ const proxy: Record<string, any> = new Proxy(raw, {
94
126
  get(target, key, receiver) {
95
- if (typeof key === "string") {
96
- trackerStack[trackerStack.length - 1]?.add(path ? `${path}.${key}` : key)
97
- }
98
- return Reflect.get(target, key, receiver)
127
+ if (key === RAW) return target
128
+ if (key === STORE) return path === ""
129
+ if (typeof key !== "string") return Reflect.get(target, key, receiver)
130
+ if (path === "" && key in storeApi) return storeApi[key]
131
+
132
+ const dotKey = path ? `${path}.${key}` : key
133
+ trackerStack[trackerStack.length - 1]?.add(dotKey)
134
+
135
+ // nested objects are wrapped here rather than up front, so the object
136
+ // handed to $reactive is never rewritten
137
+ const value = Reflect.get(target, key, receiver)
138
+ if (isStore(value)) return value
139
+
140
+ const raw = toRaw(value)
141
+ return isWrappable(raw) ? wrap(raw, dotKey) : raw
99
142
  },
100
143
  set(target, key: string, value, receiver) {
101
144
  // an assignment delegated up the prototype chain from a derived scope
@@ -110,21 +153,24 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
110
153
  }
111
154
 
112
155
  const dotKey = path ? `${path}.${key}` : key
113
- if (value && typeof value === "object" && isPlainData(value)) {
114
- value = makeReactive(value, dotKey)
115
- }
156
+ // store the raw value, never a proxy - including one of our own, so
157
+ // that `list = [list[1], list[0]]` doesn't write proxies back into the
158
+ // data. Reads re-wrap it, from the cache, as the very same proxy. A
159
+ // whole store assigned in is the exception: it stays as it is
160
+ const stored = isStore(value) ? value : toRaw(value)
116
161
  const isNewKey = !Object.prototype.hasOwnProperty.call(target, key)
117
- target[key] = value
118
- notify(dotKey, value, isNewKey)
162
+ target[key] = stored
163
+ const notified = isStore(stored) || !isWrappable(stored) ? stored : wrap(stored, dotKey)
164
+ notify(dotKey, notified, isNewKey)
119
165
  return true
120
166
  }
121
167
  })
122
168
 
123
- reactiveProxies.add(proxy)
169
+ proxies.set(raw, proxy)
124
170
  return proxy
125
171
  }
126
172
 
127
- const reactive = makeReactive(data, "") as ReactiveDeepData<T>
173
+ const reactive = wrap(toRaw(data), "") as ReactiveDeepData<T>
128
174
 
129
175
  const $on = (dotKey: string, listener: ChangeListener, { immediate = false }: ListenerOptions = {}): Unsubscribe => {
130
176
  if (!exactListeners.has(dotKey)) exactListeners.set(dotKey, new Set())
@@ -158,9 +204,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
158
204
  return () => { effects.delete(effect) }
159
205
  }
160
206
 
161
- Object.defineProperty(reactive, "$on", { value: $on, enumerable: false })
162
- Object.defineProperty(reactive, "$onAny", { value: $onAny, enumerable: false })
163
- Object.defineProperty(reactive, "$effect", { value: $effect, enumerable: false })
207
+ storeApi.$on = $on
208
+ storeApi.$onAny = $onAny
209
+ storeApi.$effect = $effect
164
210
 
165
211
  return reactive
166
212
  }
package/src/transform.ts CHANGED
@@ -48,9 +48,33 @@ const skipBlockComment = (src: string, start: number): number => {
48
48
  return end === -1 ? src.length : end + 2
49
49
  }
50
50
 
51
- // end of a statement starting at `start`: the first newline or `;` that isn't
52
- // inside a string/comment or unbalanced brackets, so multi-line RHS like a
53
- // wrapped function call or template literal stays in one piece
51
+ // index of the next thing that isn't whitespace or a comment
52
+ const skipToToken = (src: string, start: number): number => {
53
+ let i = start
54
+ while (i < src.length) {
55
+ if (/\s/.test(src[i])) { i++; continue }
56
+ if (src[i] === "/" && src[i + 1] === "/") { i = skipLineComment(src, i); continue }
57
+ if (src[i] === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
58
+ break
59
+ }
60
+ return i
61
+ }
62
+
63
+ // tokens that can't *start* a statement, so a line beginning with one is
64
+ // continuing the previous expression rather than opening a new statement -
65
+ // the same call JS's automatic semicolon insertion makes. Unary-only forms
66
+ // (!, ~, ++, --) are deliberately absent: those do start a statement, and JS
67
+ // inserts the semicolon before them
68
+ const CONTINUATION_RE = /^(\?\.|\?\?|&&|\|\||\*\*|[.,+\-*/%&|^<>=?:([])/
69
+
70
+ // end of a statement starting at `start`: the first `;` or line break that
71
+ // isn't inside a string/comment or unbalanced brackets. A line break only
72
+ // ends the statement if the next line can't continue it, so leading-dot
73
+ // method chains and multi-line operator chains stay in one piece:
74
+ //
75
+ // $: total = items
76
+ // .filter(item => item.active) <- still the same statement
77
+ // .length
54
78
  const findStatementEnd = (src: string, start: number): number => {
55
79
  let depth = 0
56
80
  let i = start
@@ -61,7 +85,13 @@ const findStatementEnd = (src: string, start: number): number => {
61
85
  if (ch === "/" && src[i + 1] === "*") { i = skipBlockComment(src, i); continue }
62
86
  if ("([{".includes(ch)) depth++
63
87
  else if (")]}".includes(ch)) depth--
64
- else if (depth <= 0 && (ch === "\n" || ch === ";")) return i
88
+ else if (depth <= 0 && ch === ";") return i
89
+ else if (depth <= 0 && ch === "\n") {
90
+ const next = skipToToken(src, i + 1)
91
+ if (next >= src.length || !CONTINUATION_RE.test(src.slice(next, next + 2))) return i
92
+ i = next
93
+ continue
94
+ }
65
95
  i++
66
96
  }
67
97
  return src.length
package/src/vite.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFile } from "node:fs/promises"
2
- import type { Plugin } from "vite"
2
+ import { relative } from "node:path"
3
+ import { preprocessCSS } from "vite"
4
+ import type { Plugin, ResolvedConfig } from "vite"
3
5
 
4
6
  // Vite plugin: import .html single-file components as modules.
5
7
  //
@@ -8,9 +10,12 @@ import type { Plugin } from "vite"
8
10
  //
9
11
  // The imported value is a Component79 built from the file's source - the same
10
12
  // thing `await Component79.fetch(url)` resolves to, but bundled at build time
11
- // instead of fetched at runtime. The plugin is a pure loader: the component
12
- // source is inlined verbatim (no transforms), so a file keeps working
13
- // unchanged if it's ever served from public/ and loaded with fetch instead.
13
+ // instead of fetched at runtime. The component source is inlined verbatim, so
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.
14
19
  //
15
20
  // Only .html files imported from other modules are claimed; entry points
16
21
  // (index.html) have no importer and imports carrying an explicit query
@@ -60,6 +65,49 @@ const hoistableImports = (source: string, include: RegExp): string[] => {
60
65
  return [...specifiers]
61
66
  }
62
67
 
68
+ // a <style> block with its attribute string, so `lang` can be read and the
69
+ // content replaced. Attribute values are matched as quoted chunks so a ">"
70
+ // inside one doesn't end the tag early
71
+ const STYLE_BLOCK_RE = /<style((?:"[^"]*"|'[^']*'|[^>"'])*)>([\s\S]*?)<\/style\s*>/gi
72
+ const LANG_ATTR_RE = /\blang\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i
73
+
74
+ // compiles <style lang="scss|less|styl|sass"> blocks to plain CSS with Vite's
75
+ // own preprocessing (the same call @vitejs/plugin-vue makes), so the runtime
76
+ // only ever sees CSS. The preprocessor picks its parser from the extension,
77
+ // and resolving relative @use/@import against a filename in the component's
78
+ // own directory is what makes `@use "./vars"` work. Files the preprocessor
79
+ // pulls in are registered as watch deps, so editing a partial re-runs HMR for
80
+ // every component that uses it. `lang` is dropped from the emitted tag: what
81
+ // the runtime parses is a plain <style> (with `scoped` and the rest intact)
82
+ const compileStyleBlocks = async (
83
+ source: string,
84
+ file: string,
85
+ config: ResolvedConfig,
86
+ addWatchFile: (id: string) => void
87
+ ): Promise<string> => {
88
+ const blocks = [...source.matchAll(STYLE_BLOCK_RE)]
89
+ const compiled = await Promise.all(
90
+ blocks.map(async ([, attrs, content]) => {
91
+ const lang = attrs.match(LANG_ATTR_RE)
92
+ if (!lang) return null
93
+ const extension = lang[1] ?? lang[2] ?? lang[3]
94
+ const result = await preprocessCSS(content, `${file}.${extension}`, config)
95
+ result.deps?.forEach(addWatchFile)
96
+ return { attrs: attrs.replace(LANG_ATTR_RE, "").trimEnd(), css: result.code }
97
+ })
98
+ )
99
+
100
+ let out = ""
101
+ let last = 0
102
+ blocks.forEach((block, i) => {
103
+ const done = compiled[i]
104
+ if (!done) return
105
+ out += source.slice(last, block.index) + `<style${done.attrs}>${done.css}</style>`
106
+ last = block.index + block[0].length
107
+ })
108
+ return out + source.slice(last)
109
+ }
110
+
63
111
  // the emitted module. Literal import("...") specifiers found in the
64
112
  // component's scripts become real module imports, handed to Component79 as a
65
113
  // resolution map: at runtime $__import checks the map before falling back to
@@ -76,7 +124,7 @@ const hoistableImports = (source: string, include: RegExp): string[] => {
76
124
  // an instance only used as a definition (nested component clones can't be
77
125
  // reached from here) falls back to a full reload. `mountRoot` is internal to
78
126
  // Component79, but plugin and runtime ship in lockstep from the same package.
79
- const componentModule = (source: string, include: RegExp): string => {
127
+ const componentModule = (source: string, include: RegExp, filename: string): string => {
80
128
  const hoisted = hoistableImports(source, include)
81
129
  const imports = hoisted
82
130
  .map((spec, i) =>
@@ -93,6 +141,7 @@ ${imports}
93
141
 
94
142
  const src = ${JSON.stringify(source)}
95
143
  const modules = ${modulesMap}
144
+ const filename = ${JSON.stringify(filename)}
96
145
 
97
146
  let component
98
147
 
@@ -103,6 +152,7 @@ if (import.meta.hot && import.meta.hot.data.component) {
103
152
  prior.scripts = next.scripts
104
153
  prior.styles = next.styles
105
154
  prior.modules = modules
155
+ prior.filename = filename
106
156
  const root = prior.mountRoot
107
157
  if (root) {
108
158
  prior.mount(root, { ...prior.data })
@@ -111,7 +161,7 @@ if (import.meta.hot && import.meta.hot.data.component) {
111
161
  }
112
162
  component = prior
113
163
  } else {
114
- component = new Component79(src, { modules })
164
+ component = new Component79(src, { modules, filename })
115
165
  }
116
166
 
117
167
  if (import.meta.hot) {
@@ -127,10 +177,16 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
127
177
  const include = options.include ?? /\.html$/
128
178
  const { exclude } = options
129
179
 
180
+ let config: ResolvedConfig | null = null
181
+
130
182
  return {
131
183
  name: "jq79",
132
184
  enforce: "pre",
133
185
 
186
+ configResolved(resolved) {
187
+ config = resolved
188
+ },
189
+
134
190
  async resolveId(source, importer) {
135
191
  if (!importer) return null // entry points are never components
136
192
  if (source.includes("?")) return null // ?raw, ?url, ... keep their meaning
@@ -145,7 +201,15 @@ export function jq79(options: Jq79PluginOptions = {}): Plugin {
145
201
  async load(id) {
146
202
  if (!id.endsWith(COMPONENT_QUERY)) return null
147
203
  const file = id.slice(0, -COMPONENT_QUERY.length)
148
- return { code: componentModule(await readFile(file, "utf8"), include), map: null }
204
+
205
+ let source = await readFile(file, "utf8")
206
+ if (config) source = await compileStyleBlocks(source, file, config, dep => this.addWatchFile(dep))
207
+
208
+ // the runtime names the component's setup scripts after this, so devtools
209
+ // shows a path the user recognizes instead of an anonymous VM script
210
+ const filename = config ? relative(config.root, file) : file
211
+
212
+ return { code: componentModule(source, include, filename), map: null }
149
213
  },
150
214
  }
151
215
  }
Binary file