jq79 0.1.0

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/dist/jk79.js ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "jq79",
3
+ "version": "0.1.0",
4
+ "description": "Mini JS library",
5
+ "keywords": [
6
+ "njs",
7
+ "jquery"
8
+ ],
9
+ "homepage": "https://github.com/jgermade/jk79#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/jgermade/jk79/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/jgermade/jk79.git"
16
+ },
17
+ "license": "ISC",
18
+ "author": "Jesus Germade <jesus@germade.dev>",
19
+ "type": "module",
20
+ "main": "dist/q.js",
21
+ "scripts": {
22
+ "test": "vitest run"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.1.1",
26
+ "jsdom": "^29.1.1",
27
+ "typescript": "^7.0.2",
28
+ "vitest": "^4.1.10"
29
+ }
30
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest"
2
+ import { $, $$, parseComponent, createReactiveDeepData } from "./jk79"
3
+
4
+ describe("$", () => {
5
+ beforeEach(() => {
6
+ document.body.innerHTML = `
7
+ <div id="root">
8
+ <span class="item">a</span>
9
+ <span class="item">b</span>
10
+ </div>
11
+ `
12
+ })
13
+
14
+ it("queries the document when given a selector string", () => {
15
+ expect($("#root")).not.toBeNull()
16
+ expect($("#root")?.tagName).toBe("DIV")
17
+ })
18
+
19
+ it("returns null when a selector string matches nothing", () => {
20
+ expect($("#missing")).toBeNull()
21
+ })
22
+
23
+ it("queries within an element when given an element + selector", () => {
24
+ const root = document.getElementById("root")!
25
+ expect($(root, ".item")?.textContent).toBe("a")
26
+ })
27
+ })
28
+
29
+ describe("$$", () => {
30
+ beforeEach(() => {
31
+ document.body.innerHTML = `
32
+ <div id="root">
33
+ <span class="item">a</span>
34
+ <span class="item">b</span>
35
+ </div>
36
+ `
37
+ })
38
+
39
+ it("returns an array of all matches for a selector string", () => {
40
+ const items = $$(".item")
41
+ expect(Array.isArray(items)).toBe(true)
42
+ expect(items).toHaveLength(2)
43
+ })
44
+
45
+ it("returns an empty array when nothing matches", () => {
46
+ expect($$(".missing")).toEqual([])
47
+ })
48
+
49
+ it("returns matches scoped to an element when given an element + selector", () => {
50
+ const root = document.getElementById("root")!
51
+ const items = $$(root, ".item")
52
+ expect(items).toHaveLength(2)
53
+ expect(items.map(el => el.textContent)).toEqual(["a", "b"])
54
+ })
55
+ })
56
+
57
+ describe("parseComponent", () => {
58
+ const component = `
59
+ <script :setup="{ fname, lname }">
60
+ const fullName = \`\${fname} \${lname}\`
61
+ </script>
62
+
63
+ <div :bind="{ fullName }"></div>
64
+ <div class="full-name">
65
+ {{ fullName }}
66
+ </div>
67
+
68
+ <style>
69
+ .full-name {
70
+ color: red;
71
+ }
72
+ </style>
73
+ `
74
+
75
+ it("extracts scripts with their attrs and content", () => {
76
+ const { scripts } = parseComponent(component)
77
+
78
+ expect(scripts).toHaveLength(1)
79
+ expect(scripts[0].attrs).toEqual({ ":setup": "{ fname, lname }" })
80
+ expect(scripts[0].content).toContain("const fullName")
81
+ })
82
+
83
+ it("extracts styles with their attrs and content", () => {
84
+ const { styles } = parseComponent(component)
85
+
86
+ expect(styles).toHaveLength(1)
87
+ expect(styles[0].attrs).toEqual({})
88
+ expect(styles[0].content).toContain(".full-name")
89
+ })
90
+
91
+ it("builds a template AST excluding script/style tags", () => {
92
+ const { template } = parseComponent(component)
93
+
94
+ expect(template).toHaveLength(2)
95
+
96
+ expect(template[0]).toEqual({
97
+ tag: "div",
98
+ attrs: { ":bind": "{ fullName }" },
99
+ children: [],
100
+ })
101
+
102
+ expect(template[1]).toEqual({
103
+ tag: "div",
104
+ attrs: { class: "full-name" },
105
+ children: ["{{ fullName }}"],
106
+ })
107
+ })
108
+
109
+ it("recurses into nested elements", () => {
110
+ const { template } = parseComponent(`
111
+ <ul>
112
+ <li>one</li>
113
+ <li>two</li>
114
+ </ul>
115
+ `)
116
+
117
+ expect(template).toEqual([
118
+ {
119
+ tag: "ul",
120
+ attrs: {},
121
+ children: [
122
+ { tag: "li", attrs: {}, children: ["one"] },
123
+ { tag: "li", attrs: {}, children: ["two"] },
124
+ ],
125
+ },
126
+ ])
127
+ })
128
+
129
+ it("returns empty collections for a component with no scripts/styles", () => {
130
+ const { scripts, styles } = parseComponent("<div>just a div</div>")
131
+
132
+ expect(scripts).toEqual([])
133
+ expect(styles).toEqual([])
134
+ })
135
+ })
136
+
137
+ describe("createReactiveDeepData", () => {
138
+ it("emits the top-level key on a shallow set", () => {
139
+ const onChange = vi.fn()
140
+ const state = createReactiveDeepData({ name: "a" }, onChange)
141
+
142
+ state.name = "b"
143
+
144
+ expect(onChange).toHaveBeenCalledWith("name", "b")
145
+ })
146
+
147
+ it("emits a dot path when setting a property on a nested object present at creation", () => {
148
+ const onChange = vi.fn()
149
+ const state = createReactiveDeepData({ user: { address: { city: "NYC" } } }, onChange)
150
+
151
+ state.user.address.city = "LA"
152
+
153
+ expect(onChange).toHaveBeenCalledWith("user.address.city", "LA")
154
+ })
155
+
156
+ it("emits a dot path when setting a property on an object assigned after creation", () => {
157
+ const onChange = vi.fn()
158
+ const state = createReactiveDeepData({ user: null as any }, onChange)
159
+
160
+ state.user = { address: { city: "NYC" } }
161
+ onChange.mockClear()
162
+
163
+ state.user.address.city = "LA"
164
+
165
+ expect(onChange).toHaveBeenCalledWith("user.address.city", "LA")
166
+ })
167
+
168
+ it("keeps deeper paths reactive after a whole subtree is replaced", () => {
169
+ const onChange = vi.fn()
170
+ const state = createReactiveDeepData({ user: { address: { city: "NYC" } } }, onChange)
171
+
172
+ state.user.address = { city: "LA" }
173
+ onChange.mockClear()
174
+
175
+ state.user.address.city = "SF"
176
+
177
+ expect(onChange).toHaveBeenCalledWith("user.address.city", "SF")
178
+ })
179
+ })
package/src/jk79.ts ADDED
@@ -0,0 +1,144 @@
1
+
2
+ export const $ = (selectorOrEl: string | Element, selector?: string) =>
3
+ typeof selectorOrEl === "string"
4
+ ? document.querySelector(selectorOrEl)
5
+ : selectorOrEl.querySelector(selector || "")
6
+
7
+ export const $$ = (selectorOrEl: string | Element, selector?: string) => Array.from(
8
+ typeof selectorOrEl === "string"
9
+ ? document.querySelectorAll(selectorOrEl)
10
+ : selectorOrEl.querySelectorAll(selector || "")
11
+ )
12
+
13
+ type TemplateNode = {
14
+ tag: string
15
+ attrs: Record<string, string>
16
+ children: (TemplateNode | string)[]
17
+ }
18
+
19
+ type TagBlock = {
20
+ attrs: Record<string, string>
21
+ content: string
22
+ }
23
+
24
+ const elementAttrs = (el: Element): Record<string, string> =>
25
+ Object.fromEntries(Array.from(el.attributes).map(attr => [attr.name, attr.value]))
26
+
27
+ const elementToAST = (el: Element): TemplateNode => ({
28
+ tag: el.tagName.toLowerCase(),
29
+ attrs: elementAttrs(el),
30
+ children: Array.from(el.childNodes).flatMap((node): (TemplateNode | string)[] => {
31
+ if (node.nodeType === Node.TEXT_NODE) {
32
+ const text = node.textContent?.trim() ?? ""
33
+ return text ? [text] : []
34
+ }
35
+ if (node.nodeType === Node.ELEMENT_NODE) {
36
+ return [elementToAST(node as Element)]
37
+ }
38
+ return []
39
+ })
40
+ })
41
+
42
+ const interpolate = (template: string, data: Record<string, any>): string =>
43
+ template.replace(/{{\s*(\w+)\s*}}/g, (_, key) => data[key] ?? "")
44
+
45
+ export const createReactiveDeepData = (
46
+ data: Record<string, any>,
47
+ onChange: (dotKey: string, value: any) => void,
48
+ path = ""
49
+ ): Record<string, any> => {
50
+ Object.entries(data).forEach(([key, value]) => {
51
+ if (value && typeof value === "object") {
52
+ data[key] = createReactiveDeepData(value, onChange, path ? `${path}.${key}` : key)
53
+ }
54
+ })
55
+
56
+ return new Proxy(data, {
57
+ set(target, key: string, value) {
58
+ const dotKey = path ? `${path}.${key}` : key
59
+ if (value && typeof value === "object") {
60
+ value = createReactiveDeepData(value, onChange, dotKey)
61
+ }
62
+ target[key] = value
63
+ onChange(dotKey, value)
64
+ return true
65
+ }
66
+ })
67
+ }
68
+
69
+ const renderComponent = (component: Component79, data: Record<string, any> = {}): Node => {
70
+ const fragment = document.createDocumentFragment()
71
+
72
+ component.template.forEach(node => {
73
+ const el = document.createElement(node.tag)
74
+ Object.entries(node.attrs).forEach(([key, value]) => {
75
+ el.setAttribute(key, value)
76
+ })
77
+ node.children.forEach(child => {
78
+ if (typeof child === "string") {
79
+ el.appendChild(document.createTextNode(interpolate(child, data)))
80
+ } else {
81
+ el.appendChild(renderComponent(new Component79([child], [], []), data))
82
+ }
83
+ })
84
+ fragment.appendChild(el)
85
+ })
86
+ return fragment
87
+ }
88
+
89
+ class Component79 {
90
+ constructor(public template: TemplateNode[], public scripts: TagBlock[], public styles: TagBlock[]) {
91
+ this.template = template
92
+ this.scripts = scripts
93
+ this.styles = styles
94
+ }
95
+ }
96
+
97
+ // converts a string of HTML into a AST (Abstract Syntax Tree) representation of the component
98
+ // Returns an object with the following properties:
99
+ // - template: the template of the component in a AST representation
100
+ // - scripts: {
101
+ // attrs: the attributes of the script tag
102
+ // content: the content of the script tag
103
+ // }[]
104
+ // - styles: {
105
+ // attrs: the attributes of the style tag
106
+ // content: the content of the style tag
107
+ // }[]
108
+ export const parseComponent = (component: string) => {
109
+ // example
110
+ // <script :setup="{ fname, lname }">
111
+ // const fullName = `${fname} ${lname}`
112
+ // </script>
113
+ //
114
+ // <div :bind="{ fullName }" />
115
+ // <div class="full-name">
116
+ // {{ fullName }}
117
+ // </div>
118
+ //
119
+ // <style>
120
+ // .full-name {
121
+ // color: red;
122
+ // }
123
+ // </style>
124
+
125
+ // parsed as the content of a <template> so leading <script>/<style> tags
126
+ // aren't reparented into <head> by the HTML parser
127
+ const parsedDOM = new DOMParser().parseFromString(`<template>${component}</template>`, "text/html")
128
+ const root = parsedDOM.querySelector("template") as HTMLTemplateElement
129
+
130
+ const scripts: TagBlock[] = []
131
+ const styles: TagBlock[] = []
132
+ const template: TemplateNode[] = []
133
+
134
+ Array.from(root.content.children).forEach(el => {
135
+ const block = { attrs: elementAttrs(el), content: el.textContent ?? "" }
136
+
137
+ if (el.tagName === "SCRIPT") scripts.push(block)
138
+ else if (el.tagName === "STYLE") styles.push(block)
139
+ else template.push(elementToAST(el))
140
+ })
141
+
142
+ return new Component79(template, scripts, styles)
143
+ }
144
+
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ },
7
+ })