jq79 0.3.21 → 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/README.md +4 -1
- package/assets/code-coverage.svg +19 -0
- package/assets/github-dark.svg +5 -0
- package/assets/github-light.svg +5 -0
- package/assets/github-white.afdesign +0 -0
- package/assets/npm-logo.svg +8 -0
- package/dist/jq79.cjs +8 -8
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +4 -0
- package/dist/jq79.global.js +8 -8
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +8 -8
- package/dist/jq79.js.map +1 -1
- package/dist/vite.cjs +38 -3
- package/dist/vite.cjs.map +1 -1
- package/dist/vite.js +38 -3
- package/dist/vite.js.map +1 -1
- package/package.json +4 -1
- package/src/jq79.ts +169 -19
- package/src/reactive.ts +75 -29
- package/src/transform.ts +34 -4
- package/src/vite.ts +71 -7
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
|
|
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
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
|
85
|
-
|
|
116
|
+
const isWrappable = (value: any): value is Record<string, any> =>
|
|
117
|
+
value !== null && typeof value === "object" && isPlainData(value)
|
|
86
118
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
125
|
+
const proxy: Record<string, any> = new Proxy(raw, {
|
|
94
126
|
get(target, key, receiver) {
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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] =
|
|
118
|
-
|
|
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
|
-
|
|
169
|
+
proxies.set(raw, proxy)
|
|
124
170
|
return proxy
|
|
125
171
|
}
|
|
126
172
|
|
|
127
|
-
const reactive =
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
}
|