@inglorious/ssx 0.1.4 → 0.2.1

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.
@@ -0,0 +1,67 @@
1
+ import path from "node:path"
2
+
3
+ import { createStore } from "@inglorious/web"
4
+ import { expect, it } from "vitest"
5
+
6
+ import { renderPage } from "./render"
7
+
8
+ const PAGES_DIR = path.join(__dirname, "__fixtures__", "pages")
9
+
10
+ const DEFAULT_OPTIONS = { stripLitMarkers: true }
11
+
12
+ it("should render a static page fragment", async () => {
13
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
14
+
15
+ const store = createStore({
16
+ types: { about: module.about },
17
+ updateMode: "manual",
18
+ })
19
+
20
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
21
+
22
+ expect(html).toMatchSnapshot()
23
+ })
24
+
25
+ it("should render a whole static page", async () => {
26
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
27
+
28
+ const store = createStore({
29
+ types: { about: module.about },
30
+ updateMode: "manual",
31
+ })
32
+
33
+ const html = await renderPage(store, module, {
34
+ ...DEFAULT_OPTIONS,
35
+ wrap: true,
36
+ })
37
+
38
+ expect(html).toMatchSnapshot()
39
+ })
40
+
41
+ it("should render a page with entity", async () => {
42
+ const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
43
+
44
+ const store = createStore({
45
+ types: { about: module.about },
46
+ entities: { about: { type: "about", name: "Us" } },
47
+ updateMode: "manual",
48
+ })
49
+
50
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
51
+
52
+ expect(html).toMatchSnapshot()
53
+ })
54
+
55
+ it("should render a page with pre-fetched data", async () => {
56
+ const module = await import(path.resolve(path.join(PAGES_DIR, "posts.js")))
57
+
58
+ const store = createStore({
59
+ types: { posts: module.posts },
60
+ entities: { posts: { type: "posts", name: "Antony", posts: [] } },
61
+ updateMode: "manual",
62
+ })
63
+
64
+ const html = await renderPage(store, module, DEFAULT_OPTIONS)
65
+
66
+ expect(html).toMatchSnapshot()
67
+ })
package/src/router.js ADDED
@@ -0,0 +1,230 @@
1
+ import path from "node:path"
2
+ import { pathToFileURL } from "node:url"
3
+
4
+ import { glob } from "glob"
5
+
6
+ const NEXT_MATCH = 1
7
+
8
+ const STATIC_SEGMENT_WEIGHT = 3
9
+ const CATCH_ALL_ROUTE_WEIGHT = -10
10
+ const SCORE_MULTIPLIER = 0.1
11
+
12
+ /**
13
+ * Get all static paths for SSG build.
14
+ * This calls getStaticPaths() on dynamic route pages.
15
+ */
16
+ export async function getPages(pagesDir = "pages") {
17
+ const routes = await getRoutes(pagesDir)
18
+ const pages = []
19
+
20
+ for (const route of routes) {
21
+ if (isDynamic(route.pattern)) {
22
+ // Dynamic route - call getStaticPaths if it exists
23
+ try {
24
+ const module = await import(pathToFileURL(path.resolve(route.filePath)))
25
+
26
+ if (typeof module.getStaticPaths === "function") {
27
+ const paths = await module.getStaticPaths()
28
+
29
+ for (const pathOrObject of paths) {
30
+ const urlPath =
31
+ typeof pathOrObject === "string"
32
+ ? pathOrObject
33
+ : pathOrObject.path
34
+
35
+ const params = extractParams(route, urlPath)
36
+
37
+ pages.push({
38
+ path: urlPath,
39
+ modulePath: route.modulePath,
40
+ filePath: route.filePath,
41
+ params,
42
+ })
43
+ }
44
+ } else {
45
+ console.warn(
46
+ `Dynamic route ${route.filePath} has no getStaticPaths export. ` +
47
+ `It will be skipped during SSG.`,
48
+ )
49
+ }
50
+ } catch (error) {
51
+ console.error(`Error loading ${route.filePath}:`, error)
52
+ }
53
+ } else {
54
+ // Static route - add directly
55
+ pages.push({
56
+ path: route.pattern === "" ? "/" : route.pattern,
57
+ modulePath: route.modulePath,
58
+ filePath: route.filePath,
59
+ params: {},
60
+ })
61
+ }
62
+ }
63
+
64
+ return pages
65
+ }
66
+
67
+ /**
68
+ * Resolve a URL to a page file and extract params.
69
+ * Used by dev server for on-demand rendering.
70
+ */
71
+ export async function resolvePage(url, pagesDir = "pages") {
72
+ const routes = await getRoutes(pagesDir)
73
+
74
+ // Normalize URL (remove query string and hash)
75
+ const [fullPath] = url.split("?")
76
+ const [normalizedUrl] = fullPath.split("#")
77
+
78
+ for (const route of routes) {
79
+ const match = route.regex.exec(normalizedUrl)
80
+
81
+ if (match) {
82
+ const params = {}
83
+ route.params.forEach((param, i) => {
84
+ params[param] = match[i + NEXT_MATCH]
85
+ })
86
+
87
+ return {
88
+ filePath: route.filePath,
89
+ params,
90
+ }
91
+ }
92
+ }
93
+
94
+ return null
95
+ }
96
+
97
+ /**
98
+ * Discovers all pages in the pages directory.
99
+ * Returns an array of route objects with pattern matching info.
100
+ */
101
+ export async function getRoutes(pagesDir = "pages") {
102
+ // Find all .js and .ts files in pages directory
103
+ const files = await glob("**/*.{js,ts}", {
104
+ cwd: pagesDir,
105
+ ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
106
+ posix: true,
107
+ })
108
+
109
+ const routes = files.map((file) => {
110
+ const filePath = path.join(pagesDir, file)
111
+ const pattern = filePathToPattern(file)
112
+ const { regex, params } = patternToRegex(pattern)
113
+
114
+ return {
115
+ pattern,
116
+ modulePath: file,
117
+ filePath,
118
+ regex,
119
+ params,
120
+ }
121
+ })
122
+
123
+ // Sort routes by specificity (most specific first)
124
+ routes.sort((a, b) => {
125
+ const aScore = routeSpecificity(a.pattern)
126
+ const bScore = routeSpecificity(b.pattern)
127
+ return bScore - aScore
128
+ })
129
+
130
+ return routes
131
+ }
132
+
133
+ /**
134
+ * Convert a file path to a route pattern.
135
+ * pages/index.js -> /
136
+ * pages/about.js -> /about
137
+ * pages/blog/[slug].js -> /blog/:slug
138
+ * pages/api/[...path].js -> /api/*
139
+ */
140
+ function filePathToPattern(file) {
141
+ let pattern = file
142
+ .replace(/\\/g, "/")
143
+ .replace(/\.(js|ts)$/, "") // Remove extension
144
+ .replace(/\/index$/, "") // index becomes root of directory
145
+ .replace(/^index$/, "") // Handle root index
146
+ .replace(/__(\w+)/g, "*") // __path becomes *
147
+ .replace(/_(\w+)/g, ":$1") // _id becomes :id
148
+
149
+ // Normalize to start with /
150
+ return "/" + pattern.replace(/^\//, "")
151
+ }
152
+
153
+ /**
154
+ * Convert a route pattern to a regex and extract parameter names.
155
+ */
156
+ function patternToRegex(pattern) {
157
+ const params = []
158
+
159
+ // Replace :param with capture groups
160
+ let regexStr = pattern.replace(/:(\w+)/g, (_, param) => {
161
+ params.push(param)
162
+ return "([^/]+)"
163
+ })
164
+
165
+ // Replace * with greedy capture
166
+ regexStr = regexStr.replace(/\*/g, () => {
167
+ params.push("path")
168
+ return "(.*)"
169
+ })
170
+
171
+ // Exact match
172
+ regexStr = "^" + regexStr + "$"
173
+
174
+ return {
175
+ regex: new RegExp(regexStr),
176
+ params,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Calculate route specificity for sorting.
182
+ * Higher score = more specific = should match first.
183
+ */
184
+ function routeSpecificity(pattern) {
185
+ let score = 0
186
+
187
+ // Static segments add 3 points each
188
+ const segments = pattern.split("/").filter(Boolean)
189
+ segments.forEach((segment) => {
190
+ if (!segment.startsWith(":") && segment !== "*") {
191
+ score += STATIC_SEGMENT_WEIGHT
192
+ }
193
+ })
194
+
195
+ // Dynamic segments add 1 point
196
+ const dynamicCount = (pattern.match(/:/g) || []).length
197
+ score += dynamicCount
198
+
199
+ // Catch-all routes have lowest priority (subtract points)
200
+ if (pattern.includes("*")) {
201
+ score += CATCH_ALL_ROUTE_WEIGHT
202
+ }
203
+
204
+ // Longer paths are more specific
205
+ score += segments.length * SCORE_MULTIPLIER
206
+
207
+ return score
208
+ }
209
+
210
+ /**
211
+ * Check if a pattern is dynamic (contains params or wildcards).
212
+ */
213
+ function isDynamic(pattern) {
214
+ return pattern.includes(":") || pattern.includes("*")
215
+ }
216
+
217
+ /**
218
+ * Extract params from a URL based on a route.
219
+ */
220
+ function extractParams(route, url) {
221
+ const match = route.regex.exec(url)
222
+ if (!match) return {}
223
+
224
+ const params = {}
225
+ route.params.forEach((param, i) => {
226
+ params[param] = match[i + NEXT_MATCH]
227
+ })
228
+
229
+ return params
230
+ }
@@ -0,0 +1,103 @@
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(6)
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
+ expect(pages).toMatchSnapshot()
94
+
95
+ // Dynamic route without getStaticPaths should be skipped (and warn)
96
+ const blogPage = pages.find((p) => p.path.includes("/blog/"))
97
+ expect(blogPage).toBeUndefined()
98
+
99
+ expect(consoleSpy).toHaveBeenCalled()
100
+ expect(consoleSpy.mock.calls[1][0]).toContain("has no getStaticPaths")
101
+ })
102
+ })
103
+ })
@@ -0,0 +1,70 @@
1
+ import { getModuleName } from "../module.js"
2
+
3
+ /**
4
+ * Generate the code that goes inside the <!-- SSX --> marker.
5
+ * This creates the types and entities objects for the client-side store.
6
+ */
7
+ export function generateApp(store, renderedPages) {
8
+ // Collect all unique page modules and their exports
9
+ const pageImports = new Map()
10
+ const routeEntries = []
11
+
12
+ for (const { page, module } of renderedPages) {
13
+ const importPath = "@/pages/" + page.modulePath
14
+
15
+ const exportName = getModuleName(module)
16
+
17
+ pageImports.set(importPath, exportName)
18
+
19
+ routeEntries.push(` '${page.path}': '${exportName}'`)
20
+ }
21
+
22
+ // Generate import statements
23
+ const imports = Array.from(pageImports.entries())
24
+ .map(
25
+ ([importPath, exportName]) =>
26
+ `import { ${exportName} } from '${importPath}'`,
27
+ )
28
+ .join("\n")
29
+
30
+ // Generate routes object
31
+ const routes = routeEntries.join(",\n")
32
+
33
+ // Generate type registrations
34
+ const typeEntries = Array.from(pageImports.values())
35
+ .map((name) => ` ${name}`)
36
+ .join(",\n")
37
+
38
+ return `import { createDevtools, createStore, mount, router } from "@inglorious/web"
39
+ ${imports}
40
+
41
+ const types = {
42
+ router,
43
+ ${typeEntries}
44
+ }
45
+
46
+ const entities = {
47
+ router: {
48
+ type: 'router',
49
+ routes: {
50
+ ${routes}
51
+ }
52
+ },
53
+ ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
54
+ }
55
+
56
+ const middlewares = []
57
+ if (import.meta.env.DEV) {
58
+ middlewares.push(createDevtools().middleware)
59
+ }
60
+
61
+ const store = createStore({ types, entities, middlewares })
62
+
63
+ const root = document.getElementById("root")
64
+ root.innerHTML = ""
65
+
66
+ mount(store, (api) => {
67
+ const { route } = api.getEntity("router")
68
+ return api.render(route, { allowType: true })
69
+ }, root)`
70
+ }
@@ -0,0 +1,23 @@
1
+ export function generateLitLoader(options = {}) {
2
+ return `let seed = ${options.seed}
3
+ let mode = "seeded"
4
+
5
+ const originalRandom = Math.random
6
+ Math.random = random
7
+
8
+ await import("@inglorious/web")
9
+
10
+ queueMicrotask(() => {
11
+ Math.random = originalRandom
12
+ mode = "normal"
13
+ })
14
+
15
+ function random() {
16
+ if (mode === "seeded") {
17
+ seed = (seed * 1664525 + 1013904223) % 4294967296
18
+ return seed / 4294967296
19
+ }
20
+ return originalRandom()
21
+ }
22
+ `
23
+ }
@@ -0,0 +1,5 @@
1
+ export function generateMain() {
2
+ return `import "./lit-loader.js"
3
+ await import("./app.js")
4
+ `
5
+ }
@@ -0,0 +1,38 @@
1
+ import path from "node:path"
2
+
3
+ import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
4
+
5
+ /**
6
+ * Generate Vite config for building the client bundle
7
+ */
8
+ export function createViteConfig(options = {}) {
9
+ const { rootDir = "src", outDir = "dist" } = options
10
+
11
+ return {
12
+ root: process.cwd(),
13
+ plugins: [minifyTemplateLiterals()],
14
+ build: {
15
+ outDir,
16
+ emptyOutDir: false, // Don't delete HTML files we already generated
17
+ rollupOptions: {
18
+ input: {
19
+ main: path.resolve(outDir, "main.js"),
20
+ },
21
+ output: {
22
+ entryFileNames: "[name].js",
23
+ chunkFileNames: "[name].[hash].js",
24
+ assetFileNames: "[name].[ext]",
25
+ manualChunks(id) {
26
+ // if (id.includes("node_modules/@inglorious")) return "inglorious"
27
+ if (id.includes("node_modules")) return "vendor"
28
+ },
29
+ },
30
+ },
31
+ },
32
+ resolve: {
33
+ alias: {
34
+ "@": path.resolve(process.cwd(), rootDir),
35
+ },
36
+ },
37
+ }
38
+ }