@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 +4 -4
- package/package.json +3 -2
- package/src/__fixtures__/pages/about.js +7 -0
- package/src/__fixtures__/pages/api/[...path].js +7 -0
- package/src/__fixtures__/pages/blog/[slug].js +7 -0
- package/src/__fixtures__/pages/index.js +7 -0
- package/src/__fixtures__/pages/posts/[id].js +11 -0
- package/src/build.js +13 -0
- package/src/render.js +17 -0
- package/src/router.js +225 -0
- package/src/router.test.js +117 -0
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
|
|
724
|
+
### Built-in Events
|
|
725
725
|
|
|
726
|
-
- **`create(entity
|
|
727
|
-
- **`destroy(entity
|
|
728
|
-
- **`morph(
|
|
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.
|
|
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.
|
|
44
|
+
"@inglorious/web": "2.6.1"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"prettier": "^3.6.2",
|
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
|
+
})
|