@inglorious/ssx 1.1.0 → 1.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.
@@ -1,4 +1,4 @@
1
- import { createGetPageOption } from "../page-options.js"
1
+ import { createGetPageOption } from "../utils/page-options.js"
2
2
  import { toHTML } from "./html.js"
3
3
 
4
4
  const DEFAULT_OPTIONS = {
@@ -11,6 +11,16 @@ const DEFAULT_OPTIONS = {
11
11
  scripts: [],
12
12
  }
13
13
 
14
+ /**
15
+ * Renders a specific page using the store and page options.
16
+ * It resolves page-specific metadata (title, meta, etc.) before rendering.
17
+ *
18
+ * @param {Object} store - The application store.
19
+ * @param {Object} page - The page object (from router).
20
+ * @param {Object} entity - The entity associated with the page.
21
+ * @param {Object} [options] - Global site options/defaults.
22
+ * @returns {Promise<string>} The rendered HTML.
23
+ */
14
24
  export async function renderPage(store, page, entity, options = {}) {
15
25
  const { moduleName, module } = page
16
26
 
@@ -1,3 +1,18 @@
1
+ /**
2
+ * Default layout function for wrapping content in a full HTML document.
3
+ *
4
+ * @param {string} body - The body content HTML.
5
+ * @param {Object} options - Layout options.
6
+ * @param {string} [options.lang="en"] - Language attribute.
7
+ * @param {string} [options.charset="UTF-8"] - Character set.
8
+ * @param {string} [options.title=""] - Page title.
9
+ * @param {Object} [options.meta={}] - Meta tags.
10
+ * @param {string[]} [options.styles=[]] - Stylesheets.
11
+ * @param {string} [options.head=""] - Additional head content.
12
+ * @param {string[]} [options.scripts=[]] - Scripts.
13
+ * @param {boolean} [options.isDev] - Whether in dev mode.
14
+ * @returns {string} The full HTML document.
15
+ */
1
16
  export function layout(body, options) {
2
17
  const {
3
18
  lang = "en",
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { layout } from "./layout"
4
+
5
+ describe("layout", () => {
6
+ it("should render default layout structure", () => {
7
+ const html = layout("<h1>Hello</h1>", {})
8
+ expect(html).toContain("<!DOCTYPE html>")
9
+ expect(html).toContain('<html lang="en">')
10
+ expect(html).toContain('<meta charset="UTF-8" />')
11
+ expect(html).toContain('<div id="root"><h1>Hello</h1></div>')
12
+ expect(html).toContain('<script type="module" src="/main.js"></script>')
13
+ })
14
+
15
+ it("should render with custom title and language", () => {
16
+ const html = layout("", { title: "My Page", lang: "fr" })
17
+ expect(html).toContain("<title>My Page</title>")
18
+ expect(html).toContain('<html lang="fr">')
19
+ })
20
+
21
+ it("should render meta tags", () => {
22
+ const html = layout("", {
23
+ meta: { description: "Desc", viewport: "width=device-width" },
24
+ })
25
+ expect(html).toContain('<meta name="description" content="Desc">')
26
+ expect(html).toContain(
27
+ '<meta name="viewport" content="width=device-width">',
28
+ )
29
+ })
30
+
31
+ it("should render styles", () => {
32
+ const html = layout("", { styles: ["/style.css", "/theme.css"] })
33
+ expect(html).toContain('<link rel="stylesheet" href="/style.css">')
34
+ expect(html).toContain('<link rel="stylesheet" href="/theme.css">')
35
+ })
36
+
37
+ it("should render scripts", () => {
38
+ const html = layout("", { scripts: ["/app.js"] })
39
+ expect(html).toContain('<script type="module" src="/app.js"></script>')
40
+ })
41
+
42
+ it("should render additional head content", () => {
43
+ const html = layout("", { head: '<link rel="icon" href="/favicon.ico">' })
44
+ expect(html).toContain('<link rel="icon" href="/favicon.ico">')
45
+ })
46
+
47
+ it("should include vite client in dev mode", () => {
48
+ const html = layout("", { isDev: true })
49
+ expect(html).toContain(
50
+ '<script type="module" src="/@vite/client"></script>',
51
+ )
52
+ })
53
+
54
+ it("should not include vite client in prod mode", () => {
55
+ const html = layout("", { isDev: false })
56
+ expect(html).not.toContain("/@vite/client")
57
+ })
58
+ })
@@ -1,111 +1,114 @@
1
1
  import path from "node:path"
2
+ import { pathToFileURL } from "node:url"
2
3
 
3
4
  import { createStore } from "@inglorious/web"
4
- import { expect, it } from "vitest"
5
+ import { describe, expect, it } from "vitest"
5
6
 
6
7
  import { renderPage } from "."
7
8
 
8
- const ROOT_DIR = path.join(__dirname, "..", "__fixtures__")
9
+ const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
9
10
  const PAGES_DIR = path.join(ROOT_DIR, "pages")
10
11
 
11
12
  const DEFAULT_OPTIONS = { stripLitMarkers: true }
12
13
 
13
- it("should render a static page fragment", async () => {
14
- const module = await import(path.resolve(path.join(PAGES_DIR, "index.js")))
15
- const page = { path: "/", moduleName: "index", module }
14
+ describe("renderPage", () => {
15
+ it("should render a static page fragment", async () => {
16
+ const module = await import(pathToFileURL(path.join(PAGES_DIR, "index.js")))
17
+ const page = { path: "/", moduleName: "index", module }
16
18
 
17
- const store = createStore({
18
- types: { index: module.index },
19
- updateMode: "manual",
20
- })
21
-
22
- const html = await renderPage(store, page, undefined, DEFAULT_OPTIONS)
23
-
24
- expect(html).toMatchSnapshot()
25
- })
19
+ const store = createStore({
20
+ types: { index: module.index },
21
+ updateMode: "manual",
22
+ })
26
23
 
27
- it("should render a page with entity", async () => {
28
- const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
29
- const page = { path: "/about", moduleName: "about", module }
30
- const entity = { type: "about", name: "Us" }
24
+ const html = await renderPage(store, page, undefined, DEFAULT_OPTIONS)
31
25
 
32
- const store = createStore({
33
- types: { about: module.about },
34
- entities: { about: entity },
35
- updateMode: "manual",
26
+ expect(html).toMatchSnapshot()
36
27
  })
37
28
 
38
- const html = await renderPage(store, page, entity, DEFAULT_OPTIONS)
29
+ it("should render a page with entity", async () => {
30
+ const module = await import(pathToFileURL(path.join(PAGES_DIR, "about.js")))
31
+ const page = { path: "/about", moduleName: "about", module }
32
+ const entity = { type: "about", name: "Us" }
39
33
 
40
- expect(html).toMatchSnapshot()
41
- })
34
+ const store = createStore({
35
+ types: { about: module.about },
36
+ entities: { about: entity },
37
+ updateMode: "manual",
38
+ })
42
39
 
43
- it("should render a page with metadata", async () => {
44
- const module = await import(path.resolve(path.join(PAGES_DIR, "about.js")))
45
- const page = { path: "/about", moduleName: "about", module }
46
- const entity = { type: "about", name: "Us" }
40
+ const html = await renderPage(store, page, entity, DEFAULT_OPTIONS)
47
41
 
48
- const store = createStore({
49
- types: { about: module.about },
50
- entities: { about: entity },
51
- updateMode: "manual",
42
+ expect(html).toMatchSnapshot()
52
43
  })
53
44
 
54
- const html = await renderPage(store, page, module, {
55
- ...DEFAULT_OPTIONS,
56
- wrap: true,
57
- })
45
+ it("should render a page with metadata", async () => {
46
+ const module = await import(pathToFileURL(path.join(PAGES_DIR, "about.js")))
47
+ const page = { path: "/about", moduleName: "about", module }
48
+ const entity = { type: "about", name: "Us" }
58
49
 
59
- expect(html).toMatchSnapshot()
60
- })
50
+ const store = createStore({
51
+ types: { about: module.about },
52
+ entities: { about: entity },
53
+ updateMode: "manual",
54
+ })
61
55
 
62
- it("should render a page with pre-fetched data", async () => {
63
- const module = await import(path.resolve(path.join(PAGES_DIR, "blog.js")))
64
- const page = { path: "/blog", moduleName: "blog", module }
65
- const entity = {
66
- type: "blog",
67
- name: "Antony",
68
- posts: [
69
- { id: 1, title: "First Post" },
70
- { id: 2, title: "Second Post" },
71
- { id: 3, title: "Third Post" },
72
- ],
73
- }
74
-
75
- const store = createStore({
76
- types: { blog: module.blog },
77
- entities: { blog: entity },
78
- updateMode: "manual",
79
- })
80
-
81
- const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
82
-
83
- expect(html).toMatchSnapshot()
84
- })
56
+ const html = await renderPage(store, page, module, {
57
+ ...DEFAULT_OPTIONS,
58
+ wrap: true,
59
+ })
85
60
 
86
- it("should render a dynamic page", async () => {
87
- const module = await import(
88
- path.resolve(path.join(PAGES_DIR, "posts/_slug.js"))
89
- )
90
- const page = { path: "/posts/1", moduleName: "post", module }
91
- const entity = {
92
- type: "blog",
93
- name: "Antony",
94
- post: {
95
- id: 1,
96
- title: "First Post",
97
- date: "2026-01-04",
98
- body: "Hello world!",
99
- },
100
- }
101
-
102
- const store = createStore({
103
- types: { blog: module.post },
104
- entities: { post: entity },
105
- updateMode: "manual",
61
+ expect(html).toMatchSnapshot()
106
62
  })
107
63
 
108
- const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
64
+ it("should render a page with pre-fetched data", async () => {
65
+ const module = await import(pathToFileURL(path.join(PAGES_DIR, "blog.js")))
66
+ const page = { path: "/blog", moduleName: "blog", module }
67
+ const entity = {
68
+ type: "blog",
69
+ name: "Antony",
70
+ posts: [
71
+ { id: 1, title: "First Post" },
72
+ { id: 2, title: "Second Post" },
73
+ { id: 3, title: "Third Post" },
74
+ ],
75
+ }
76
+
77
+ const store = createStore({
78
+ types: { blog: module.blog },
79
+ entities: { blog: entity },
80
+ updateMode: "manual",
81
+ })
82
+
83
+ const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
84
+
85
+ expect(html).toMatchSnapshot()
86
+ })
109
87
 
110
- expect(html).toMatchSnapshot()
88
+ it("should render a dynamic page", async () => {
89
+ const module = await import(
90
+ pathToFileURL(path.join(PAGES_DIR, "posts", "_slug.js"))
91
+ )
92
+ const page = { path: "/posts/1", moduleName: "post", module }
93
+ const entity = {
94
+ type: "blog",
95
+ name: "Antony",
96
+ post: {
97
+ id: 1,
98
+ title: "First Post",
99
+ date: "2026-01-04",
100
+ body: "Hello world!",
101
+ },
102
+ }
103
+
104
+ const store = createStore({
105
+ types: { blog: module.post },
106
+ entities: { post: entity },
107
+ updateMode: "manual",
108
+ })
109
+
110
+ const html = await renderPage(store, page, module, DEFAULT_OPTIONS)
111
+
112
+ expect(html).toMatchSnapshot()
113
+ })
111
114
  })
@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"
3
3
 
4
4
  import { glob } from "glob"
5
5
 
6
- import { getModuleName } from "../module.js"
6
+ import { getModuleName } from "../utils/module.js"
7
7
 
8
8
  const NEXT_MATCH = 1
9
9
 
@@ -12,55 +12,69 @@ const CATCH_ALL_ROUTE_WEIGHT = -10
12
12
  const SCORE_MULTIPLIER = 0.1
13
13
 
14
14
  /**
15
- * Get all static paths for SSG build.
15
+ * Scans the pages directory and returns a list of all pages to be built.
16
+ * For dynamic routes, it calls the `staticPaths` export of the page module
17
+ * to generate all possible paths.
18
+ *
19
+ * @param {string} pagesDir - The directory containing page files.
20
+ * @param {Function} [loader] - Optional loader function (e.g. vite.ssrLoadModule).
21
+ * @returns {Promise<Array<Object>>} A list of page objects with metadata.
16
22
  */
17
- export async function getPages(pagesDir = "pages") {
23
+ export async function getPages(pagesDir = "pages", loader) {
18
24
  const routes = await getRoutes(pagesDir)
19
25
  const pages = []
26
+ const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
20
27
 
21
28
  for (const route of routes) {
22
- const module = await import(pathToFileURL(path.resolve(route.filePath)))
23
- const moduleName = getModuleName(module)
24
-
25
- if (isDynamic(route.pattern)) {
26
- let { staticPaths = [] } = module
27
- if (typeof staticPaths === "function") {
28
- staticPaths = await staticPaths()
29
- }
29
+ try {
30
+ const module = await load(route.filePath)
31
+ const moduleName = getModuleName(module)
32
+
33
+ if (isDynamic(route.pattern)) {
34
+ let { staticPaths = [] } = module
35
+ if (typeof staticPaths === "function") {
36
+ staticPaths = await staticPaths()
37
+ }
30
38
 
31
- if (staticPaths.length) {
32
- for (const pathOrObject of staticPaths) {
33
- const path =
34
- typeof pathOrObject === "string" ? pathOrObject : pathOrObject.path
35
-
36
- const params = extractParams(route, path)
37
-
38
- pages.push({
39
- pattern: route.pattern,
40
- path,
41
- params,
42
- moduleName,
43
- modulePath: route.modulePath,
44
- filePath: route.filePath,
45
- })
39
+ if (staticPaths.length) {
40
+ for (const pathOrObject of staticPaths) {
41
+ const path =
42
+ typeof pathOrObject === "string"
43
+ ? pathOrObject
44
+ : pathOrObject.path
45
+
46
+ const params = extractParams(route, path)
47
+
48
+ pages.push({
49
+ pattern: route.pattern,
50
+ path,
51
+ params,
52
+ moduleName,
53
+ modulePath: route.modulePath,
54
+ filePath: route.filePath,
55
+ })
56
+ }
57
+ } else {
58
+ console.warn(
59
+ `Dynamic route ${route.filePath} has no staticPaths export. ` +
60
+ `It will be skipped during SSG.`,
61
+ )
46
62
  }
47
63
  } else {
48
- console.warn(
49
- `Dynamic route ${route.filePath} has no staticPaths export. ` +
50
- `It will be skipped during SSG.`,
51
- )
64
+ // Static route - add directly
65
+ pages.push({
66
+ pattern: route.pattern,
67
+ path: route.pattern || "/",
68
+ params: {},
69
+ module,
70
+ moduleName,
71
+ modulePath: route.modulePath,
72
+ filePath: route.filePath,
73
+ })
52
74
  }
53
- } else {
54
- // Static route - add directly
55
- pages.push({
56
- pattern: route.pattern,
57
- path: route.pattern || "/",
58
- params: {},
59
- module,
60
- moduleName,
61
- modulePath: route.modulePath,
62
- filePath: route.filePath,
63
- })
75
+ } catch (error) {
76
+ console.error(`\n❌ Failed to load page: ${route.filePath}`)
77
+ throw error
64
78
  }
65
79
  }
66
80
 
@@ -68,8 +82,12 @@ export async function getPages(pagesDir = "pages") {
68
82
  }
69
83
 
70
84
  /**
71
- * Resolve a URL to a page file and extract params.
72
- * Used by dev server for on-demand rendering.
85
+ * Resolves a URL to a specific page file and extracts route parameters.
86
+ * This is primarily used by the development server for on-demand rendering.
87
+ *
88
+ * @param {string} url - The URL to resolve (e.g., "/posts/hello").
89
+ * @param {string} pagesDir - The directory containing page files.
90
+ * @returns {Promise<{filePath: string, params: Object}|null>} The resolved page info or null if not found.
73
91
  */
74
92
  export async function resolvePage(url, pagesDir = "pages") {
75
93
  const routes = await getRoutes(pagesDir)
@@ -98,12 +116,15 @@ export async function resolvePage(url, pagesDir = "pages") {
98
116
  }
99
117
 
100
118
  /**
101
- * Discovers all pages in the pages directory.
102
- * Returns an array of route objects with pattern matching info.
119
+ * Discovers all page files and converts them into route definitions.
120
+ * Routes are sorted by specificity so that more specific routes match first.
121
+ *
122
+ * @param {string} pagesDir - The directory containing page files.
123
+ * @returns {Promise<Array<Object>>} A list of route objects.
103
124
  */
104
125
  export async function getRoutes(pagesDir = "pages") {
105
126
  // Find all .js and .ts files in pages directory
106
- const files = await glob("**/*.{js,ts}", {
127
+ const files = await glob("**/*.{js,ts,jsx,tsx}", {
107
128
  cwd: pagesDir,
108
129
  ignore: ["**/*.test.{js,ts}", "**/*.spec.{js,ts}"],
109
130
  posix: true,
@@ -134,16 +155,44 @@ export async function getRoutes(pagesDir = "pages") {
134
155
  }
135
156
 
136
157
  /**
137
- * Convert a file path to a route pattern.
138
- * pages/index.js -> /
139
- * pages/about.js -> /about
140
- * pages/blog/[slug].js -> /blog/:slug
141
- * pages/api/[...path].js -> /api/*
158
+ * Simple route matcher.
159
+ * Checks if a URL matches a route pattern (handling dynamic segments).
160
+ *
161
+ * @param {string} pattern - The route pattern (e.g. "/posts/:id").
162
+ * @param {string} url - The actual URL (e.g. "/posts/123").
163
+ * @returns {boolean} True if it matches.
164
+ */
165
+ export function matchRoute(pattern, url) {
166
+ const patternParts = pattern.split("/").filter(Boolean)
167
+ const urlParts = url.split("/").filter(Boolean)
168
+
169
+ if (patternParts.length !== urlParts.length) {
170
+ return false
171
+ }
172
+
173
+ return patternParts.every((part, i) => {
174
+ if (part.startsWith(":") || part.startsWith("[")) {
175
+ return true
176
+ }
177
+ return part === urlParts[i]
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Converts a file path to a route pattern.
183
+ * Examples:
184
+ * - pages/index.js -> /
185
+ * - pages/about.js -> /about
186
+ * - pages/blog/_slug.js -> /blog/:slug
187
+ * - pages/api/__path.js -> /api/*
188
+ *
189
+ * @param {string} file - The relative file path.
190
+ * @returns {string} The route pattern.
142
191
  */
143
192
  function filePathToPattern(file) {
144
193
  let pattern = file
145
194
  .replace(/\\/g, "/")
146
- .replace(/\.(js|ts)$/, "") // Remove extension
195
+ .replace(/\.(js|ts|jsx|tsx)$/, "") // Remove extension
147
196
  .replace(/\/index$/, "") // index becomes root of directory
148
197
  .replace(/^index$/, "") // Handle root index
149
198
  .replace(/__(\w+)/g, "*") // __path becomes *
@@ -154,7 +203,10 @@ function filePathToPattern(file) {
154
203
  }
155
204
 
156
205
  /**
157
- * Convert a route pattern to a regex and extract parameter names.
206
+ * Converts a route pattern to a regex and extracts parameter names.
207
+ *
208
+ * @param {string} pattern - The route pattern.
209
+ * @returns {{regex: RegExp, params: string[]}} The regex and parameter names.
158
210
  */
159
211
  function patternToRegex(pattern) {
160
212
  const params = []
@@ -181,8 +233,11 @@ function patternToRegex(pattern) {
181
233
  }
182
234
 
183
235
  /**
184
- * Calculate route specificity for sorting.
236
+ * Calculates route specificity for sorting.
185
237
  * Higher score = more specific = should match first.
238
+ *
239
+ * @param {string} pattern - The route pattern.
240
+ * @returns {number} The specificity score.
186
241
  */
187
242
  function routeSpecificity(pattern) {
188
243
  let score = 0
@@ -211,14 +266,21 @@ function routeSpecificity(pattern) {
211
266
  }
212
267
 
213
268
  /**
214
- * Check if a pattern is dynamic (contains params or wildcards).
269
+ * Checks if a pattern is dynamic (contains params or wildcards).
270
+ *
271
+ * @param {string} pattern - The route pattern.
272
+ * @returns {boolean} True if dynamic.
215
273
  */
216
274
  function isDynamic(pattern) {
217
275
  return pattern.includes(":") || pattern.includes("*")
218
276
  }
219
277
 
220
278
  /**
221
- * Extract params from a URL based on a route.
279
+ * Extracts params from a URL based on a route.
280
+ *
281
+ * @param {Object} route - The route object.
282
+ * @param {string} url - The URL to match.
283
+ * @returns {Object} The extracted parameters.
222
284
  */
223
285
  function extractParams(route, url) {
224
286
  const match = route.regex.exec(url)
@@ -2,9 +2,9 @@ import path from "node:path"
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest"
4
4
 
5
- import { getPages, getRoutes, resolvePage } from "./index.js"
5
+ import { getPages, getRoutes, matchRoute, resolvePage } from "./index.js"
6
6
 
7
- const ROOT_DIR = path.join(__dirname, "..", "__fixtures__")
7
+ const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
8
8
  const PAGES_DIR = path.join(ROOT_DIR, "pages")
9
9
 
10
10
  describe("router", () => {
@@ -84,6 +84,12 @@ describe("router", () => {
84
84
  const page = await resolvePage("/foo", PAGES_DIR)
85
85
  expect(page).toBeNull()
86
86
  })
87
+
88
+ it("should return null for dynamic route missing param", async () => {
89
+ // /posts/:slug requires a slug
90
+ const page = await resolvePage("/posts", PAGES_DIR)
91
+ expect(page).toBeNull()
92
+ })
87
93
  })
88
94
 
89
95
  describe("getPages", () => {
@@ -101,4 +107,29 @@ describe("router", () => {
101
107
  expect(consoleSpy.mock.calls[1][0]).toContain("has no staticPaths")
102
108
  })
103
109
  })
110
+
111
+ describe("matchRoute", () => {
112
+ it("should match static routes", () => {
113
+ expect(matchRoute("/", "/")).toBe(true)
114
+ expect(matchRoute("/about", "/about")).toBe(true)
115
+ expect(matchRoute("/about", "/contact")).toBe(false)
116
+ })
117
+
118
+ it("should match dynamic routes", () => {
119
+ expect(matchRoute("/posts/:id", "/posts/123")).toBe(true)
120
+ expect(matchRoute("/users/[id]", "/users/antony")).toBe(true)
121
+ })
122
+
123
+ it("should not match if segment length differs", () => {
124
+ expect(matchRoute("/", "/about")).toBe(false)
125
+ expect(matchRoute("/posts/:id", "/posts/123/comments")).toBe(false)
126
+ expect(matchRoute("/posts/:id", "/posts")).toBe(false)
127
+ })
128
+
129
+ it("should handle trailing slashes implicitly via split", () => {
130
+ // split('/').filter(Boolean) removes empty strings, so trailing slashes are ignored
131
+ expect(matchRoute("/about/", "/about")).toBe(true)
132
+ expect(matchRoute("/about", "/about/")).toBe(true)
133
+ })
134
+ })
104
135
  })
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Generate the code that goes inside the <!-- SSX --> marker.
3
- * This creates the types and entities objects for the client-side store.
2
+ * Generates the client-side entry point script.
3
+ * This script hydrates the store with the initial state (entities) and sets up the router.
4
+ *
5
+ * @param {Object} store - The server-side store instance containing the initial state.
6
+ * @param {Array<Object>} pages - List of page objects to generate routes for.
7
+ * @returns {string} The generated JavaScript code for the client entry point.
4
8
  */
5
9
  export function generateApp(store, pages) {
6
10
  // Collect all unique page modules and their exports