@inglorious/ssx 0.1.3 → 0.2.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/README.md CHANGED
@@ -721,11 +721,11 @@ Each handler receives three arguments:
721
721
  - `getTypes()` - type definitions (for middleware)
722
722
  - `getType(typeName)` - type definition (for overriding)
723
723
 
724
- ### Built-in Lifecycle Events
724
+ ### Built-in Events
725
725
 
726
- - **`create(entity, id)`** - triggered when entity added via `add` event
727
- - **`destroy(entity, id)`** - triggered when entity removed via `remove` event
728
- - **`morph(entity, newType)`** - triggered when entity type changes
726
+ - **`create(entity)`** - triggered when entity added via `add` event, visible only to that entity
727
+ - **`destroy(entity)`** - triggered when entity removed via `remove` event, visible only to that entity
728
+ - **`morph(typeName, newType)`** - used to change the behavior of a type on the fly
729
729
 
730
730
  ### Notify vs Dispatch
731
731
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Server-Side-X. Xecution? Xperience? Who knows.",
5
5
  "author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -39,8 +39,9 @@
39
39
  "access": "public"
40
40
  },
41
41
  "dependencies": {
42
+ "glob": "^13.0.0",
42
43
  "happy-dom": "^20.0.11",
43
- "@inglorious/web": "2.6.0"
44
+ "@inglorious/web": "2.6.1"
44
45
  },
45
46
  "devDependencies": {
46
47
  "prettier": "^3.6.2",
@@ -0,0 +1,7 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const about = {
4
+ render() {
5
+ return html`<h1>About</h1>`
6
+ },
7
+ }
@@ -0,0 +1,7 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const api = {
4
+ render() {
5
+ return html`<h1>API</h1>`
6
+ },
7
+ }
@@ -0,0 +1,7 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const blog = {
4
+ render() {
5
+ return html`<h1>Blog</h1>`
6
+ },
7
+ }
@@ -0,0 +1,7 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const index = {
4
+ render() {
5
+ return html`<h1>Index</h1>`
6
+ },
7
+ }
@@ -0,0 +1,11 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const posts = {
4
+ render() {
5
+ return html`<h1>Posts</h1>`
6
+ },
7
+ }
8
+
9
+ export async function getStaticPaths() {
10
+ return [{ path: "/posts/1", params: { id: "1" } }, "/posts/2"]
11
+ }
package/src/build.js ADDED
@@ -0,0 +1,13 @@
1
+ // import { renderPage } from "./render.js"
2
+ // import { getPages } from "./router.js"
3
+
4
+ export async function build() {
5
+ // const pages = await getPages()
6
+ // for (const page of pages) {
7
+ // const module = await import(page.filePath)
8
+ // const { html, storeConfig, renderFn } = await renderPage(module, page)
9
+ // TODO: implement this
10
+ // Inject client script and write to dist/
11
+ // await writePageToDisk(page.path, html, { storeConfig, renderFn })
12
+ // }
13
+ }
package/src/render.js ADDED
@@ -0,0 +1,17 @@
1
+ import { createStore } from "@inglorious/web"
2
+
3
+ import { toHTML } from "./html"
4
+
5
+ export async function renderPage(pageModule, context) {
6
+ const data = (await pageModule.getData?.(context)) ?? {}
7
+ const storeConfig = pageModule.getStore(data)
8
+ const store = createStore(storeConfig)
9
+
10
+ const html = toHTML(pageModule.render, store)
11
+
12
+ return {
13
+ html,
14
+ storeConfig,
15
+ renderFn: pageModule.render,
16
+ }
17
+ }
package/src/router.js ADDED
@@ -0,0 +1,225 @@
1
+ import path from "node:path"
2
+
3
+ import { glob } from "glob"
4
+
5
+ const NEXT_MATCH = 1
6
+
7
+ const STATIC_SEGMENT_WEIGHT = 3
8
+ const CATCH_ALL_ROUTE_WEIGHT = -10
9
+ const SCORE_MULTIPLIER = 0.1
10
+
11
+ /**
12
+ * Get all static paths for SSG build.
13
+ * This calls getStaticPaths() on dynamic route pages.
14
+ */
15
+ export async function getPages(pagesDir = "pages") {
16
+ const routes = await getRoutes(pagesDir)
17
+ const pages = []
18
+
19
+ for (const route of routes) {
20
+ if (isDynamic(route.pattern)) {
21
+ // Dynamic route - call getStaticPaths if it exists
22
+ try {
23
+ const module = await import(path.resolve(route.filePath))
24
+
25
+ if (typeof module.getStaticPaths === "function") {
26
+ const paths = await module.getStaticPaths()
27
+
28
+ for (const pathOrObject of paths) {
29
+ const urlPath =
30
+ typeof pathOrObject === "string"
31
+ ? pathOrObject
32
+ : pathOrObject.path
33
+
34
+ const params = extractParams(route, urlPath)
35
+
36
+ pages.push({
37
+ path: urlPath,
38
+ filePath: route.filePath,
39
+ params,
40
+ })
41
+ }
42
+ } else {
43
+ console.warn(
44
+ `Dynamic route ${route.filePath} has no getStaticPaths export. ` +
45
+ `It will be skipped during SSG.`,
46
+ )
47
+ }
48
+ } catch (error) {
49
+ console.error(`Error loading ${route.filePath}:`, error)
50
+ }
51
+ } else {
52
+ // Static route - add directly
53
+ pages.push({
54
+ path: route.pattern === "" ? "/" : route.pattern,
55
+ filePath: route.filePath,
56
+ params: {},
57
+ })
58
+ }
59
+ }
60
+
61
+ return pages
62
+ }
63
+
64
+ /**
65
+ * Resolve a URL to a page file and extract params.
66
+ * Used by dev server for on-demand rendering.
67
+ */
68
+ export async function resolvePage(url, pagesDir = "pages") {
69
+ const routes = await getRoutes(pagesDir)
70
+
71
+ // Normalize URL (remove query string and hash)
72
+ const [fullPath] = url.split("?")
73
+ const [normalizedUrl] = fullPath.split("#")
74
+
75
+ for (const route of routes) {
76
+ const match = route.regex.exec(normalizedUrl)
77
+
78
+ if (match) {
79
+ const params = {}
80
+ route.params.forEach((param, i) => {
81
+ params[param] = match[i + NEXT_MATCH]
82
+ })
83
+
84
+ return {
85
+ filePath: route.filePath,
86
+ params,
87
+ }
88
+ }
89
+ }
90
+
91
+ return null
92
+ }
93
+
94
+ /**
95
+ * Discovers all pages in the pages directory.
96
+ * Returns an array of route objects with pattern matching info.
97
+ */
98
+ export async function getRoutes(pagesDir = "pages") {
99
+ // Find all .js and .ts files in pages directory
100
+ const files = await glob("**/*.{js,ts}", {
101
+ cwd: pagesDir,
102
+ ignore: ["**/_*.{js,ts}", "**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
103
+ })
104
+
105
+ const routes = files.map((file) => {
106
+ const filePath = path.join(pagesDir, file)
107
+ const pattern = filePathToPattern(file)
108
+ const { regex, params } = patternToRegex(pattern)
109
+
110
+ return {
111
+ pattern,
112
+ filePath,
113
+ regex,
114
+ params,
115
+ }
116
+ })
117
+
118
+ // Sort routes by specificity (most specific first)
119
+ routes.sort((a, b) => {
120
+ const aScore = routeSpecificity(a.pattern)
121
+ const bScore = routeSpecificity(b.pattern)
122
+ return bScore - aScore
123
+ })
124
+
125
+ return routes
126
+ }
127
+
128
+ /**
129
+ * Convert a file path to a route pattern.
130
+ * pages/index.js -> /
131
+ * pages/about.js -> /about
132
+ * pages/blog/[slug].js -> /blog/:slug
133
+ * pages/api/[...path].js -> /api/*
134
+ */
135
+ function filePathToPattern(file) {
136
+ let pattern = file
137
+ .replace(/\\/g, "/")
138
+ .replace(/\.(js|ts)$/, "") // Remove extension
139
+ .replace(/\/index$/, "") // index becomes root of directory
140
+ .replace(/^index$/, "") // Handle root index
141
+ .replace(/\[\.\.\.(\w+)\]/g, "*") // [...path] becomes *
142
+ .replace(/\[(\w+)\]/g, ":$1") // [id] becomes :id
143
+
144
+ // Normalize to start with /
145
+ return "/" + pattern.replace(/^\//, "")
146
+ }
147
+
148
+ /**
149
+ * Convert a route pattern to a regex and extract parameter names.
150
+ */
151
+ function patternToRegex(pattern) {
152
+ const params = []
153
+
154
+ // Replace :param with capture groups
155
+ let regexStr = pattern.replace(/:(\w+)/g, (_, param) => {
156
+ params.push(param)
157
+ return "([^/]+)"
158
+ })
159
+
160
+ // Replace * with greedy capture
161
+ regexStr = regexStr.replace(/\*/g, () => {
162
+ params.push("path")
163
+ return "(.*)"
164
+ })
165
+
166
+ // Exact match
167
+ regexStr = "^" + regexStr + "$"
168
+
169
+ return {
170
+ regex: new RegExp(regexStr),
171
+ params,
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Calculate route specificity for sorting.
177
+ * Higher score = more specific = should match first.
178
+ */
179
+ function routeSpecificity(pattern) {
180
+ let score = 0
181
+
182
+ // Static segments add 3 points each
183
+ const segments = pattern.split("/").filter(Boolean)
184
+ segments.forEach((segment) => {
185
+ if (!segment.startsWith(":") && segment !== "*") {
186
+ score += STATIC_SEGMENT_WEIGHT
187
+ }
188
+ })
189
+
190
+ // Dynamic segments add 1 point
191
+ const dynamicCount = (pattern.match(/:/g) || []).length
192
+ score += dynamicCount
193
+
194
+ // Catch-all routes have lowest priority (subtract points)
195
+ if (pattern.includes("*")) {
196
+ score += CATCH_ALL_ROUTE_WEIGHT
197
+ }
198
+
199
+ // Longer paths are more specific
200
+ score += segments.length * SCORE_MULTIPLIER
201
+
202
+ return score
203
+ }
204
+
205
+ /**
206
+ * Check if a pattern is dynamic (contains params or wildcards).
207
+ */
208
+ function isDynamic(pattern) {
209
+ return pattern.includes(":") || pattern.includes("*")
210
+ }
211
+
212
+ /**
213
+ * Extract params from a URL based on a route.
214
+ */
215
+ function extractParams(route, url) {
216
+ const match = route.regex.exec(url)
217
+ if (!match) return {}
218
+
219
+ const params = {}
220
+ route.params.forEach((param, i) => {
221
+ params[param] = match[i + NEXT_MATCH]
222
+ })
223
+
224
+ return params
225
+ }
@@ -0,0 +1,117 @@
1
+ import path from "node:path"
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+
5
+ import { getPages, getRoutes, resolvePage } from "./router.js"
6
+
7
+ const FIXTURES_DIR = path.join(__dirname, "__fixtures__", "pages")
8
+
9
+ describe("router", () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks()
12
+ })
13
+
14
+ describe("getRoutes", () => {
15
+ it("should discover and sort routes correctly", async () => {
16
+ const routes = await getRoutes(FIXTURES_DIR)
17
+
18
+ // Expected order based on specificity:
19
+ // 1. /posts/:id (static 'posts' + dynamic 'id') -> score ~4.2
20
+ // 2. /blog/:slug (static 'blog' + dynamic 'slug') -> score ~4.2
21
+ // 3. /about (static 'about') -> score ~3.1
22
+ // 4. / (root) -> score 0
23
+ // 5. /api/* (catch-all) -> score negative
24
+
25
+ const patterns = routes.map((r) => r.pattern)
26
+
27
+ expect(patterns).toContain("/posts/:id")
28
+ expect(patterns).toContain("/blog/:slug")
29
+ expect(patterns).toContain("/about")
30
+ expect(patterns).toContain("/")
31
+ expect(patterns).toContain("/api/*")
32
+
33
+ // Check specific ordering constraints
34
+ // Specific routes before catch-all
35
+ expect(patterns.indexOf("/about")).toBeLessThan(
36
+ patterns.indexOf("/api/*"),
37
+ )
38
+ // Root usually comes after specific paths but before catch-all if it was a catch-all root,
39
+ // but here / is static.
40
+ // Let's just check that we found them.
41
+ expect(routes).toHaveLength(5)
42
+ })
43
+ })
44
+
45
+ describe("resolvePage", () => {
46
+ it("should resolve root page", async () => {
47
+ const page = await resolvePage("/", FIXTURES_DIR)
48
+ expect(page).not.toBeNull()
49
+ expect(page.filePath).toContain("index.js")
50
+ expect(page.params).toEqual({})
51
+ })
52
+
53
+ it("should resolve static page", async () => {
54
+ const page = await resolvePage("/about", FIXTURES_DIR)
55
+ expect(page).not.toBeNull()
56
+ expect(page.filePath).toContain("about.js")
57
+ expect(page.params).toEqual({})
58
+ })
59
+
60
+ it("should resolve dynamic page with params", async () => {
61
+ const page = await resolvePage("/blog/hello-world", FIXTURES_DIR)
62
+ expect(page).not.toBeNull()
63
+ expect(page.filePath).toContain("blog")
64
+ expect(page.params).toEqual({ slug: "hello-world" })
65
+ })
66
+
67
+ it("should resolve catch-all page", async () => {
68
+ const page = await resolvePage("/api/v1/users", FIXTURES_DIR)
69
+ expect(page).not.toBeNull()
70
+ expect(page.filePath).toContain("api")
71
+ expect(page.params).toEqual({ path: "v1/users" })
72
+ })
73
+
74
+ it("should return null for non-matching url", async () => {
75
+ // Since we have a catch-all /api/*, /api/foo matches.
76
+ // But /foo doesn't match anything except maybe if we had a root catch-all.
77
+ // We don't have a root catch-all, just /api/*.
78
+ // Wait, /blog/:slug matches /blog/foo.
79
+ // /posts/:id matches /posts/1.
80
+ // /about matches /about.
81
+ // / matches /.
82
+ // So /foo should return null.
83
+ const page = await resolvePage("/foo", FIXTURES_DIR)
84
+ expect(page).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe("getPages", () => {
89
+ it("should generate static paths for all pages", async () => {
90
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
91
+ const pages = await getPages(FIXTURES_DIR)
92
+
93
+ // Static routes
94
+ expect(pages).toContainEqual(
95
+ expect.objectContaining({ path: "/", params: {} }),
96
+ )
97
+ expect(pages).toContainEqual(
98
+ expect.objectContaining({ path: "/about", params: {} }),
99
+ )
100
+
101
+ // Dynamic routes with getStaticPaths
102
+ expect(pages).toContainEqual(
103
+ expect.objectContaining({ path: "/posts/1", params: { id: "1" } }),
104
+ )
105
+ expect(pages).toContainEqual(
106
+ expect.objectContaining({ path: "/posts/2", params: { id: "2" } }),
107
+ )
108
+
109
+ // Dynamic route without getStaticPaths should be skipped (and warn)
110
+ const blogPage = pages.find((p) => p.path.includes("/blog/"))
111
+ expect(blogPage).toBeUndefined()
112
+
113
+ expect(consoleSpy).toHaveBeenCalled()
114
+ expect(consoleSpy.mock.calls[1][0]).toContain("has no getStaticPaths")
115
+ })
116
+ })
117
+ })