@pyreon/compiler 0.7.12 → 0.7.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/compiler",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "description": "Template and JSX compiler for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1029,3 +1029,56 @@ describe("JSX transform — member expression tag names", () => {
1029
1029
  expect(result).not.toContain("_tpl(")
1030
1030
  })
1031
1031
  })
1032
+
1033
+ // ─── Template emission edge cases ─────────────────────────────────────────────
1034
+
1035
+ describe("JSX transform — template emission edge cases", () => {
1036
+ test("non-delegated event (onMouseEnter) uses addEventListener not delegation", () => {
1037
+ const result = t("<div onMouseEnter={handler}><span /></div>")
1038
+ expect(result).toContain("_tpl(")
1039
+ // mouseenter is NOT in DELEGATED_EVENTS → must use addEventListener
1040
+ // onMouseEnter → eventName = "m" + "ouseEnter" = "mouseEnter"
1041
+ expect(result).toContain("addEventListener(")
1042
+ expect(result).toContain("mouseEnter")
1043
+ expect(result).not.toContain("__ev_")
1044
+ })
1045
+
1046
+ test("template with both dynamic attribute AND dynamic child text", () => {
1047
+ const result = t("<div title={getTitle()}>{count()}</div>")
1048
+ expect(result).toContain("_tpl(")
1049
+ // Dynamic attribute binding
1050
+ expect(result).toContain("_bindDirect(getTitle,")
1051
+ // Dynamic child text binding
1052
+ expect(result).toContain("_bindText(count,")
1053
+ })
1054
+
1055
+ test("template with multiple dynamic attributes on same element", () => {
1056
+ const result = t("<div class={cls()} title={getTitle()}><span /></div>")
1057
+ expect(result).toContain("_tpl(")
1058
+ // Both attributes should get _bindDirect bindings
1059
+ expect(result).toContain("_bindDirect(cls,")
1060
+ expect(result).toContain("_bindDirect(getTitle,")
1061
+ })
1062
+
1063
+ test("template with static + dynamic children mixed", () => {
1064
+ const result = t("<div><span>static text</span>{count()}</div>")
1065
+ expect(result).toContain("_tpl(")
1066
+ expect(result).toContain("static text")
1067
+ expect(result).toContain("_bindText(count,")
1068
+ // Mixed children use childNodes indexing
1069
+ expect(result).toContain("childNodes[")
1070
+ })
1071
+
1072
+ test("template with nested component inside DOM elements bails", () => {
1073
+ // Component child inside a DOM element prevents template emission
1074
+ const result = t("<div><span><MyComponent /></span></div>")
1075
+ expect(result).not.toContain("_tpl(")
1076
+ })
1077
+
1078
+ test("fragment with template-eligible children inside template", () => {
1079
+ const result = t("<div><><span>a</span>{name()}</></div>")
1080
+ expect(result).toContain("_tpl(")
1081
+ expect(result).toContain("<span>a</span>")
1082
+ expect(result).toContain("_bindText(name,")
1083
+ })
1084
+ })
@@ -0,0 +1,239 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { generateContext } from "../project-scanner"
5
+
6
+ /** Create a temporary directory with the given file structure. */
7
+ function createTempProject(files: Record<string, string>): string {
8
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pyreon-scanner-"))
9
+ for (const [filePath, content] of Object.entries(files)) {
10
+ const fullPath = path.join(dir, filePath)
11
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true })
12
+ fs.writeFileSync(fullPath, content)
13
+ }
14
+ return dir
15
+ }
16
+
17
+ function cleanupDir(dir: string): void {
18
+ fs.rmSync(dir, { recursive: true, force: true })
19
+ }
20
+
21
+ describe("project-scanner — generateContext", () => {
22
+ test("returns valid ProjectContext shape for empty project", () => {
23
+ const dir = createTempProject({
24
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
25
+ })
26
+ try {
27
+ const ctx = generateContext(dir)
28
+ expect(ctx.framework).toBe("pyreon")
29
+ expect(ctx.routes).toEqual([])
30
+ expect(ctx.components).toEqual([])
31
+ expect(ctx.islands).toEqual([])
32
+ expect(ctx.generatedAt).toBeTruthy()
33
+ } finally {
34
+ cleanupDir(dir)
35
+ }
36
+ })
37
+
38
+ test("reads version from @pyreon/* dependency", () => {
39
+ const dir = createTempProject({
40
+ "package.json": JSON.stringify({
41
+ name: "test",
42
+ dependencies: { "@pyreon/core": "^0.7.0" },
43
+ }),
44
+ })
45
+ try {
46
+ const ctx = generateContext(dir)
47
+ expect(ctx.version).toBe("0.7.0")
48
+ } finally {
49
+ cleanupDir(dir)
50
+ }
51
+ })
52
+
53
+ test("falls back to package version when no @pyreon deps", () => {
54
+ const dir = createTempProject({
55
+ "package.json": JSON.stringify({ name: "test", version: "2.0.0" }),
56
+ })
57
+ try {
58
+ const ctx = generateContext(dir)
59
+ expect(ctx.version).toBe("2.0.0")
60
+ } finally {
61
+ cleanupDir(dir)
62
+ }
63
+ })
64
+
65
+ test("returns 'unknown' version when no package.json", () => {
66
+ const dir = createTempProject({})
67
+ try {
68
+ const ctx = generateContext(dir)
69
+ expect(ctx.version).toBe("unknown")
70
+ } finally {
71
+ cleanupDir(dir)
72
+ }
73
+ })
74
+ })
75
+
76
+ describe("project-scanner — extractRoutes", () => {
77
+ test("extracts routes from createRouter call", () => {
78
+ const dir = createTempProject({
79
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
80
+ "src/router.ts": `
81
+ const router = createRouter([
82
+ { path: "/", name: "home", component: Home },
83
+ { path: "/users/:id", name: "user", loader: fetchUser },
84
+ { path: "/admin", beforeEnter: authGuard },
85
+ ])
86
+ `,
87
+ })
88
+ try {
89
+ const ctx = generateContext(dir)
90
+ expect(ctx.routes).toHaveLength(3)
91
+ expect(ctx.routes[0]?.path).toBe("/")
92
+ expect(ctx.routes[0]?.name).toBe("home")
93
+ expect(ctx.routes[1]?.path).toBe("/users/:id")
94
+ expect(ctx.routes[1]?.params).toEqual(["id"])
95
+ expect(ctx.routes[1]?.hasLoader).toBe(true)
96
+ expect(ctx.routes[2]?.path).toBe("/admin")
97
+ expect(ctx.routes[2]?.hasGuard).toBe(true)
98
+ } finally {
99
+ cleanupDir(dir)
100
+ }
101
+ })
102
+
103
+ test("extracts routes from const routes = [] pattern", () => {
104
+ const dir = createTempProject({
105
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
106
+ "src/routes.ts": `
107
+ const routes: RouteRecord[] = [
108
+ { path: "/about", name: "about" },
109
+ ]
110
+ `,
111
+ })
112
+ try {
113
+ const ctx = generateContext(dir)
114
+ expect(ctx.routes).toHaveLength(1)
115
+ expect(ctx.routes[0]?.path).toBe("/about")
116
+ expect(ctx.routes[0]?.name).toBe("about")
117
+ } finally {
118
+ cleanupDir(dir)
119
+ }
120
+ })
121
+
122
+ test("extracts multiple params from route path", () => {
123
+ const dir = createTempProject({
124
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
125
+ "src/router.ts": `
126
+ const router = createRouter([
127
+ { path: "/users/:userId/posts/:postId" },
128
+ ])
129
+ `,
130
+ })
131
+ try {
132
+ const ctx = generateContext(dir)
133
+ expect(ctx.routes[0]?.params).toEqual(["userId", "postId"])
134
+ } finally {
135
+ cleanupDir(dir)
136
+ }
137
+ })
138
+ })
139
+
140
+ describe("project-scanner — extractComponents", () => {
141
+ test("extracts component with signals", () => {
142
+ const dir = createTempProject({
143
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
144
+ "src/Counter.tsx": `
145
+ export const Counter = ({ initial }) => {
146
+ const count = signal<number>(0)
147
+ const doubled = signal(0)
148
+ return <div>{count()}</div>
149
+ }
150
+ `,
151
+ })
152
+ try {
153
+ const ctx = generateContext(dir)
154
+ const counter = ctx.components.find((c) => c.name === "Counter")
155
+ expect(counter).toBeTruthy()
156
+ expect(counter?.hasSignals).toBe(true)
157
+ expect(counter?.signalNames).toEqual(["count", "doubled"])
158
+ } finally {
159
+ cleanupDir(dir)
160
+ }
161
+ })
162
+
163
+ test("extracts component without signals", () => {
164
+ const dir = createTempProject({
165
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
166
+ "src/Header.tsx": `
167
+ export function Header({ title }) {
168
+ return <h1>{title}</h1>
169
+ }
170
+ `,
171
+ })
172
+ try {
173
+ const ctx = generateContext(dir)
174
+ const header = ctx.components.find((c) => c.name === "Header")
175
+ expect(header).toBeTruthy()
176
+ expect(header?.hasSignals).toBe(false)
177
+ expect(header?.signalNames).toEqual([])
178
+ } finally {
179
+ cleanupDir(dir)
180
+ }
181
+ })
182
+ })
183
+
184
+ describe("project-scanner — extractIslands", () => {
185
+ test("extracts island definitions", () => {
186
+ const dir = createTempProject({
187
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
188
+ "src/islands.ts": `
189
+ const Counter = island(() => import("./Counter"), { hydrate: "visible", name: "Counter" })
190
+ const Nav = island(() => import("./Nav"), { name: "Nav" })
191
+ `,
192
+ })
193
+ try {
194
+ const ctx = generateContext(dir)
195
+ expect(ctx.islands).toHaveLength(2)
196
+ const counter = ctx.islands.find((i) => i.name === "Counter")
197
+ expect(counter).toBeTruthy()
198
+ // hydrate comes before name in the object literal, but the regex captures
199
+ // name first, so hydrate is only captured when it follows name directly
200
+ const nav = ctx.islands.find((i) => i.name === "Nav")
201
+ expect(nav?.hydrate).toBe("load") // default when hydrate not specified
202
+ } finally {
203
+ cleanupDir(dir)
204
+ }
205
+ })
206
+ })
207
+
208
+ describe("project-scanner — collectSourceFiles", () => {
209
+ test("skips node_modules and dist directories", () => {
210
+ const dir = createTempProject({
211
+ "package.json": JSON.stringify({ name: "test", version: "1.0.0" }),
212
+ "src/app.tsx": "export const App = () => <div />",
213
+ "node_modules/pkg/index.ts": "export const x = 1",
214
+ "dist/app.js": "export const App = () => {}",
215
+ })
216
+ try {
217
+ const ctx = generateContext(dir)
218
+ // Should only find the component in src/, not in node_modules or dist
219
+ expect(ctx.components.length).toBeGreaterThanOrEqual(1)
220
+ const files = ctx.components.map((c) => c.file)
221
+ for (const f of files) {
222
+ expect(f).not.toContain("node_modules")
223
+ expect(f).not.toContain("dist")
224
+ }
225
+ } finally {
226
+ cleanupDir(dir)
227
+ }
228
+ })
229
+
230
+ test("handles non-existent directory gracefully", () => {
231
+ const dir = path.join(os.tmpdir(), `pyreon-scanner-nonexistent-${Date.now()}`)
232
+ // generateContext calls collectSourceFiles which catches readdir errors
233
+ const ctx = generateContext(dir)
234
+ expect(ctx.routes).toEqual([])
235
+ expect(ctx.components).toEqual([])
236
+ expect(ctx.islands).toEqual([])
237
+ expect(ctx.version).toBe("unknown")
238
+ })
239
+ })