@inglorious/ssx 1.6.5 → 1.7.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
@@ -202,6 +202,61 @@ src/pages/
202
202
 
203
203
  Dynamic routes use underscore prefix: `_id.js`, `_slug.js`, etc.
204
204
 
205
+ ### 🌍 Internationalization (i18n)
206
+
207
+ Configure locales in `src/site.config.js`:
208
+
209
+ ```javascript
210
+ export default {
211
+ i18n: {
212
+ defaultLocale: "en",
213
+ locales: ["en", "it", "pt"],
214
+ },
215
+ }
216
+ ```
217
+
218
+ SSX generates localized variants for both static and dynamic pages:
219
+
220
+ - Static page `src/pages/about.js`:
221
+ - `/about` (default locale)
222
+ - `/it/about`
223
+ - `/pt/about`
224
+ - Dynamic page `src/pages/posts/_slug.js` with `staticPaths()`:
225
+ - `/posts/hello-world`
226
+ - `/it/posts/hello-world`
227
+ - `/pt/posts/hello-world`
228
+
229
+ On the client, SSX automatically keeps `entity.locale` in sync on navigation (`routeChange`), so pages can usually just render from `entity.locale`:
230
+
231
+ ```javascript
232
+ const messages = {
233
+ en: "Hello world!",
234
+ it: "Ciao mondo!",
235
+ pt: "Olá mundo!",
236
+ }
237
+
238
+ export const hello = {
239
+ render(entity) {
240
+ return html`<h1>${messages[entity.locale] ?? messages.en}</h1>`
241
+ },
242
+ }
243
+ ```
244
+
245
+ SSX automatically injects `page.locale` into `entity.locale` during server-side build/render too, so `load` is optional for locale initialization.
246
+
247
+ If you need custom behavior, you can still override it in `load`:
248
+
249
+ ```javascript
250
+ export async function load(entity, page) {
251
+ entity.locale = page.locale || "en"
252
+ }
253
+ ```
254
+
255
+ Notes:
256
+
257
+ - The default locale is not prefixed (`/about`, not `/en/about`).
258
+ - Locale-prefixed routes are handled in both build output and client-side navigation.
259
+
205
260
  ### ⚛️ Entity-Based State and Behavior
206
261
 
207
262
  ```javascript
@@ -552,6 +607,12 @@ export default {
552
607
  scrollBehavior: "smooth",
553
608
  },
554
609
 
610
+ // i18n routing
611
+ i18n: {
612
+ defaultLocale: "en",
613
+ locales: ["en", "it", "pt"],
614
+ },
615
+
555
616
  // Vite config passthrough
556
617
  vite: {
557
618
  server: {
@@ -693,9 +754,9 @@ Check out these example projects:
693
754
 
694
755
  - [x] TypeScript support
695
756
  - [x] Image optimization
696
- - [ ] API routes (serverless functions)
697
757
  - [x] Markdown support
698
- - [ ] i18n helpers
758
+ - [x] i18n routing and locale-aware client navigation
759
+ - [ ] API routes (serverless functions)
699
760
 
700
761
  ---
701
762
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ssx",
3
- "version": "1.6.5",
3
+ "version": "1.7.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",
@@ -27,7 +27,11 @@
27
27
  },
28
28
  "exports": {
29
29
  ".": "./types/index.d.ts",
30
- "./markdown": "./src/utils/markdown.js"
30
+ "./markdown": "./src/utils/markdown.js",
31
+ "./i18n": {
32
+ "types": "./types/i18n.d.ts",
33
+ "import": "./src/utils/i18n.js"
34
+ }
31
35
  },
32
36
  "files": [
33
37
  "bin",
@@ -57,7 +61,7 @@
57
61
  "svgo": "^4.0.0",
58
62
  "vite": "^7.1.3",
59
63
  "vite-plugin-image-optimizer": "^2.0.3",
60
- "@inglorious/web": "4.2.5"
64
+ "@inglorious/web": "4.3.0"
61
65
  },
62
66
  "devDependencies": {
63
67
  "prettier": "^3.6.2",
@@ -11,6 +11,7 @@ import {
11
11
  createManifest,
12
12
  determineRebuildPages,
13
13
  hashEntities,
14
+ hashRuntime,
14
15
  loadManifest,
15
16
  saveManifest,
16
17
  } from "./manifest.js"
@@ -52,13 +53,14 @@ export async function build(options = {}) {
52
53
  // Create a temporary Vite server to load modules (supports TS)
53
54
  const vite = await createServer({
54
55
  ...createViteConfig(mergedOptions),
56
+ mode: "production",
55
57
  server: { middlewareMode: true, hmr: false },
56
58
  appType: "custom",
57
59
  })
58
60
  const loader = (p) => vite.ssrLoadModule(p)
59
61
 
60
62
  // 0. Get all pages to build (Fail fast if source is broken)
61
- const allPages = await getPages(pagesDir, loader)
63
+ const allPages = await getPages(pagesDir, mergedOptions, loader)
62
64
  console.log(`📄 Found ${allPages.length} pages\n`)
63
65
 
64
66
  // Load previous build manifest
@@ -75,15 +77,21 @@ export async function build(options = {}) {
75
77
  }
76
78
 
77
79
  // 2. Copy public assets before generating pages (could be useful if need to read `public/data.json`)
78
- await copyPublicDir(options)
80
+ await copyPublicDir(mergedOptions)
79
81
 
80
82
  // Determine which pages need rebuilding
81
83
  const entitiesHash = await hashEntities(rootDir)
84
+ const runtimeHash = await hashRuntime()
82
85
  let pagesToChange = allPages
83
86
  let pagesToSkip = []
84
87
 
85
88
  if (manifest) {
86
- const result = await determineRebuildPages(allPages, manifest, entitiesHash)
89
+ const result = await determineRebuildPages(
90
+ allPages,
91
+ manifest,
92
+ entitiesHash,
93
+ runtimeHash,
94
+ )
87
95
  pagesToChange = result.pagesToBuild
88
96
  pagesToSkip = result.pagesToSkip
89
97
 
@@ -129,7 +137,7 @@ export async function build(options = {}) {
129
137
  // 8. Always regenerate client-side JavaScript (it's cheap and ensures consistency)
130
138
  console.log("\n📝 Generating client scripts...\n")
131
139
 
132
- const app = generateApp(store, allPages)
140
+ const app = generateApp(store, allPages, { ...mergedOptions, isDev: false })
133
141
  await fs.writeFile(path.join(outDir, "main.js"), app, "utf-8")
134
142
  console.log(` ✓ main.js\n`)
135
143
 
@@ -147,7 +155,10 @@ export async function build(options = {}) {
147
155
 
148
156
  // 11. Bundle with Vite
149
157
  console.log("\n📦 Bundling with Vite...\n")
150
- const viteConfig = createViteConfig(mergedOptions)
158
+ const viteConfig = {
159
+ ...createViteConfig(mergedOptions),
160
+ mode: "production",
161
+ }
151
162
  await viteBuild(viteConfig)
152
163
 
153
164
  await vite.close()
@@ -156,7 +167,11 @@ export async function build(options = {}) {
156
167
 
157
168
  // 13. Save manifest for next build
158
169
  if (incremental) {
159
- const newManifest = await createManifest(allGeneratedPages, entitiesHash)
170
+ const newManifest = await createManifest(
171
+ allGeneratedPages,
172
+ entitiesHash,
173
+ runtimeHash,
174
+ )
160
175
  await saveManifest(outDir, newManifest)
161
176
  }
162
177
 
@@ -3,6 +3,13 @@ import fs from "node:fs/promises"
3
3
  import path from "node:path"
4
4
 
5
5
  const MANIFEST_FILE = ".ssx-manifest.json"
6
+ const RUNTIME_FILES = [
7
+ "../scripts/app.js",
8
+ "./pages.js",
9
+ "../utils/i18n.js",
10
+ "../router/index.js",
11
+ "../render/index.js",
12
+ ]
6
13
 
7
14
  /**
8
15
  * Loads the build manifest from the previous build.
@@ -18,7 +25,7 @@ export async function loadManifest(outDir) {
18
25
  return JSON.parse(content)
19
26
  } catch {
20
27
  // No manifest exists (first build or clean build)
21
- return { pages: {}, entities: null, buildTime: null }
28
+ return { pages: {}, entities: null, runtime: null, buildTime: null }
22
29
  }
23
30
  }
24
31
 
@@ -61,6 +68,25 @@ export async function hashEntities(rootDir) {
61
68
  return await hashFile(entitiesPath)
62
69
  }
63
70
 
71
+ /**
72
+ * Computes a hash for SSX runtime internals.
73
+ * When this changes, page HTML should be regenerated even if source pages did not change.
74
+ *
75
+ * @returns {Promise<string>} Hash of runtime internals.
76
+ */
77
+ export async function hashRuntime() {
78
+ const root = import.meta.dirname
79
+ const contents = await Promise.all(
80
+ RUNTIME_FILES.map(async (relativePath) => {
81
+ const filePath = path.resolve(root, relativePath)
82
+ const content = await fs.readFile(filePath, "utf-8")
83
+ return `${relativePath}:${content}`
84
+ }),
85
+ )
86
+
87
+ return crypto.createHash("md5").update(contents.join("\n")).digest("hex")
88
+ }
89
+
64
90
  /**
65
91
  * Determines which pages need to be rebuilt.
66
92
  * Compares current file hashes against the manifest.
@@ -68,15 +94,26 @@ export async function hashEntities(rootDir) {
68
94
  * @param {Array<Object>} pages - All pages to potentially build.
69
95
  * @param {Object} manifest - Previous build manifest.
70
96
  * @param {string} entitiesHash - Current entities hash.
97
+ * @param {string} runtimeHash - Current SSX runtime hash.
71
98
  * @returns {Promise<{pagesToBuild: Array<Object>, pagesToSkip: Array<Object>}>} Object with pagesToBuild and pagesSkipped.
72
99
  */
73
- export async function determineRebuildPages(pages, manifest, entitiesHash) {
100
+ export async function determineRebuildPages(
101
+ pages,
102
+ manifest,
103
+ entitiesHash,
104
+ runtimeHash,
105
+ ) {
74
106
  // If entities changed, rebuild all pages
75
107
  if (manifest.entities !== entitiesHash) {
76
108
  console.log("📦 Entities changed, rebuilding all pages\n")
77
109
  return { pagesToBuild: pages, pagesToSkip: [] }
78
110
  }
79
111
 
112
+ if (manifest.runtime !== runtimeHash) {
113
+ console.log("🔁 SSX runtime changed, rebuilding all pages\n")
114
+ return { pagesToBuild: pages, pagesToSkip: [] }
115
+ }
116
+
80
117
  const pagesToBuild = []
81
118
  const pagesToSkip = []
82
119
 
@@ -99,9 +136,10 @@ export async function determineRebuildPages(pages, manifest, entitiesHash) {
99
136
  *
100
137
  * @param {Array<Object>} renderedPages - All rendered pages.
101
138
  * @param {string} entitiesHash - Hash of entities file.
139
+ * @param {string} runtimeHash - Hash of SSX runtime internals.
102
140
  * @returns {Promise<Object>} New manifest.
103
141
  */
104
- export async function createManifest(renderedPages, entitiesHash) {
142
+ export async function createManifest(renderedPages, entitiesHash, runtimeHash) {
105
143
  const pages = {}
106
144
 
107
145
  for (const page of renderedPages) {
@@ -115,6 +153,7 @@ export async function createManifest(renderedPages, entitiesHash) {
115
153
  return {
116
154
  pages,
117
155
  entities: entitiesHash,
156
+ runtime: runtimeHash,
118
157
  buildTime: new Date().toISOString(),
119
158
  }
120
159
  }
@@ -7,6 +7,7 @@ import {
7
7
  determineRebuildPages,
8
8
  hashEntities,
9
9
  hashFile,
10
+ hashRuntime,
10
11
  loadManifest,
11
12
  saveManifest,
12
13
  } from "./manifest"
@@ -37,7 +38,12 @@ describe("manifest", () => {
37
38
  fs.readFile.mockRejectedValue(new Error("ENOENT"))
38
39
 
39
40
  const result = await loadManifest("dist")
40
- expect(result).toEqual({ pages: {}, entities: null, buildTime: null })
41
+ expect(result).toEqual({
42
+ pages: {},
43
+ entities: null,
44
+ runtime: null,
45
+ buildTime: null,
46
+ })
41
47
  })
42
48
  })
43
49
 
@@ -80,11 +86,20 @@ describe("manifest", () => {
80
86
  })
81
87
  })
82
88
 
89
+ describe("hashRuntime", () => {
90
+ it("should hash SSX runtime files", async () => {
91
+ fs.readFile.mockResolvedValue("runtime")
92
+ const hash = await hashRuntime()
93
+ expect(typeof hash).toBe("string")
94
+ expect(hash.length).toBe(32)
95
+ })
96
+ })
97
+
83
98
  describe("determineRebuildPages", () => {
84
99
  it("should rebuild all if entities hash changed", async () => {
85
100
  const pages = [{ path: "/" }]
86
101
  const manifest = { entities: "old" }
87
- const result = await determineRebuildPages(pages, manifest, "new")
102
+ const result = await determineRebuildPages(pages, manifest, "new", "rt")
88
103
 
89
104
  expect(result.pagesToBuild).toEqual(pages)
90
105
  expect(result.pagesToSkip).toEqual([])
@@ -113,13 +128,35 @@ describe("manifest", () => {
113
128
  return ""
114
129
  })
115
130
 
116
- const result = await determineRebuildPages(pages, manifest, "hash")
131
+ const result = await determineRebuildPages(
132
+ pages,
133
+ { ...manifest, runtime: "same-rt" },
134
+ "hash",
135
+ "same-rt",
136
+ )
117
137
 
118
138
  expect(result.pagesToBuild).toHaveLength(1)
119
139
  expect(result.pagesToBuild[0].path).toBe("/changed")
120
140
  expect(result.pagesToSkip).toHaveLength(1)
121
141
  expect(result.pagesToSkip[0].path).toBe("/same")
122
142
  })
143
+
144
+ it("should rebuild all if runtime hash changed", async () => {
145
+ const pages = [{ path: "/" }]
146
+ const manifest = { entities: "hash", runtime: "old-rt" }
147
+ const result = await determineRebuildPages(
148
+ pages,
149
+ manifest,
150
+ "hash",
151
+ "new-rt",
152
+ )
153
+
154
+ expect(result.pagesToBuild).toEqual(pages)
155
+ expect(result.pagesToSkip).toEqual([])
156
+ expect(consoleSpy).toHaveBeenCalledWith(
157
+ expect.stringContaining("runtime changed"),
158
+ )
159
+ })
123
160
  })
124
161
 
125
162
  describe("createManifest", () => {
@@ -129,6 +166,7 @@ describe("manifest", () => {
129
166
  { path: "/about", filePath: "about.js" },
130
167
  ]
131
168
  const entitiesHash = "entities-hash"
169
+ const runtimeHash = "runtime-hash"
132
170
 
133
171
  fs.readFile.mockImplementation(async (path) => {
134
172
  if (path === "index.js") return "index content"
@@ -136,9 +174,14 @@ describe("manifest", () => {
136
174
  return ""
137
175
  })
138
176
 
139
- const manifest = await createManifest(renderedPages, entitiesHash)
177
+ const manifest = await createManifest(
178
+ renderedPages,
179
+ entitiesHash,
180
+ runtimeHash,
181
+ )
140
182
 
141
183
  expect(manifest.entities).toBe(entitiesHash)
184
+ expect(manifest.runtime).toBe(runtimeHash)
142
185
  expect(manifest.buildTime).toBeDefined()
143
186
  expect(manifest.pages["/"]).toEqual({
144
187
  hash: "176b689259e8d68ef0aa869fd3b3be45",
@@ -32,6 +32,10 @@ export async function generatePages(store, pages, options = {}, loader) {
32
32
  page.module = module
33
33
 
34
34
  const entity = api.getEntity(page.moduleName)
35
+ if (page.locale) {
36
+ entity.locale = page.locale
37
+ }
38
+
35
39
  if (module.load) {
36
40
  await module.load(entity, page)
37
41
  }
@@ -80,4 +80,54 @@ describe("generatePages", () => {
80
80
  expect(renderPage).toHaveBeenCalled()
81
81
  expect(extractPageMetadata).not.toHaveBeenCalled()
82
82
  })
83
+
84
+ it("should set entity.locale from page.locale even without load()", async () => {
85
+ const entity = {}
86
+ const store = { _api: { getEntity: vi.fn(() => entity) } }
87
+ const pages = [
88
+ {
89
+ path: "/it/p4",
90
+ filePath: "virtual-page.js",
91
+ moduleName: "p4",
92
+ locale: "it",
93
+ },
94
+ ]
95
+ const loader = vi.fn(async () => ({ render: () => {} }))
96
+
97
+ vi.clearAllMocks()
98
+ renderPage.mockResolvedValue("<html></html>")
99
+ extractPageMetadata.mockReturnValue({})
100
+
101
+ await generatePages(store, pages, {}, loader)
102
+
103
+ expect(entity.locale).toBe("it")
104
+ })
105
+
106
+ it("should overwrite entity.locale for each localized page render", async () => {
107
+ const entity = { locale: "en" }
108
+ const store = { _api: { getEntity: vi.fn(() => entity) } }
109
+ const pages = [
110
+ {
111
+ path: "/it/p5",
112
+ filePath: "virtual-page.js",
113
+ moduleName: "p5",
114
+ locale: "it",
115
+ },
116
+ {
117
+ path: "/pt/p5",
118
+ filePath: "virtual-page.js",
119
+ moduleName: "p5",
120
+ locale: "pt",
121
+ },
122
+ ]
123
+ const loader = vi.fn(async () => ({ render: () => {} }))
124
+
125
+ vi.clearAllMocks()
126
+ renderPage.mockResolvedValue("<html></html>")
127
+ extractPageMetadata.mockReturnValue({})
128
+
129
+ await generatePages(store, pages, {}, loader)
130
+
131
+ expect(entity.locale).toBe("pt")
132
+ })
83
133
  })
package/src/dev/index.js CHANGED
@@ -51,7 +51,7 @@ export async function dev(options = {}) {
51
51
  }
52
52
 
53
53
  // Get all pages on each request (in dev mode, pages might be added/removed)
54
- const pages = await getPages(pagesDir, loader)
54
+ const pages = await getPages(pagesDir, mergedOptions, loader)
55
55
 
56
56
  // Find matching page
57
57
  const page = pages.find((p) => matchRoute(p.path, url))
@@ -64,12 +64,16 @@ export async function dev(options = {}) {
64
64
  const store = await generateStore(pages, mergedOptions, loader)
65
65
 
66
66
  const entity = store._api.getEntity(page.moduleName)
67
+ if (page.locale) {
68
+ entity.locale = page.locale
69
+ }
70
+
67
71
  if (module.load) {
68
72
  await module.load(entity, page)
69
73
  }
70
74
 
71
75
  // Generate and update the virtual app file BEFORE rendering
72
- const app = generateApp(store, pages)
76
+ const app = generateApp(store, pages, { ...mergedOptions, isDev: true })
73
77
  virtualFiles.set("/main.js", app)
74
78
 
75
79
  // Invalidate the virtual module to ensure Vite picks up changes
@@ -17,10 +17,11 @@ const SCORE_MULTIPLIER = 0.1
17
17
  * to generate all possible paths.
18
18
  *
19
19
  * @param {string} pagesDir - The directory containing page files.
20
+ * @param {Object} [options] - config object with i18n configuration { defaultLocale, locales }.
20
21
  * @param {Function} [loader] - Optional loader function (e.g. vite.ssrLoadModule).
21
22
  * @returns {Promise<Array<Object>>} A list of page objects with metadata.
22
23
  */
23
- export async function getPages(pagesDir = "pages", loader) {
24
+ export async function getPages(pagesDir = "pages", options, loader) {
24
25
  const routes = await getRoutes(pagesDir)
25
26
  const pages = []
26
27
  const load = loader || ((p) => import(pathToFileURL(path.resolve(p))))
@@ -45,7 +46,7 @@ export async function getPages(pagesDir = "pages", loader) {
45
46
 
46
47
  const params = extractParams(route, path)
47
48
 
48
- pages.push({
49
+ addPages(pages, options, {
49
50
  pattern: route.pattern,
50
51
  path,
51
52
  params,
@@ -61,8 +62,7 @@ export async function getPages(pagesDir = "pages", loader) {
61
62
  )
62
63
  }
63
64
  } else {
64
- // Static route - add directly
65
- pages.push({
65
+ addPages(pages, options, {
66
66
  pattern: route.pattern,
67
67
  path: route.pattern || "/",
68
68
  params: {},
@@ -81,38 +81,30 @@ export async function getPages(pagesDir = "pages", loader) {
81
81
  return pages
82
82
  }
83
83
 
84
- /**
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.
91
- */
92
- export async function resolvePage(url, pagesDir = "pages") {
93
- const routes = await getRoutes(pagesDir)
94
-
95
- // Normalize URL (remove query string and hash)
96
- const [fullPath] = url.split("?")
97
- const [normalizedUrl] = fullPath.split("#")
84
+ function addPages(pages, options = {}, pageData) {
85
+ const { i18n = {} } = options
98
86
 
99
- for (const route of routes) {
100
- const match = route.regex.exec(normalizedUrl)
87
+ if (!i18n.locales?.length) {
88
+ pages.push(pageData)
89
+ return
90
+ }
101
91
 
102
- if (match) {
103
- const params = {}
104
- route.params.forEach((param, i) => {
105
- params[param] = match[i + NEXT_MATCH]
106
- })
92
+ for (const locale of i18n.locales) {
93
+ const isDefault = locale === i18n.defaultLocale
94
+ const prefix = isDefault ? "" : `/${locale}`
107
95
 
108
- return {
109
- filePath: route.filePath,
110
- params,
111
- }
96
+ let localizedPath = prefix + pageData.path
97
+ // Handle root path: / -> / (default), /fr (fr)
98
+ if (pageData.path === "/" && !isDefault) {
99
+ localizedPath = prefix
112
100
  }
113
- }
114
101
 
115
- return null
102
+ pages.push({
103
+ ...pageData,
104
+ path: localizedPath,
105
+ locale,
106
+ })
107
+ }
116
108
  }
117
109
 
118
110
  /**
@@ -2,7 +2,7 @@ import path from "node:path"
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest"
4
4
 
5
- import { getPages, getRoutes, matchRoute, resolvePage } from "./index.js"
5
+ import { getPages, getRoutes, matchRoute } from "./index.js"
6
6
 
7
7
  const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
8
8
  const PAGES_DIR = path.join(ROOT_DIR, "src", "pages")
@@ -39,56 +39,7 @@ describe("router", () => {
39
39
  // Root usually comes after specific paths but before catch-all if it was a catch-all root,
40
40
  // but here / is static.
41
41
  // Let's just check that we found them.
42
- expect(routes).toHaveLength(6)
43
- })
44
- })
45
-
46
- describe("resolvePage", () => {
47
- it("should resolve root page", async () => {
48
- const page = await resolvePage("/", PAGES_DIR)
49
- expect(page).not.toBeNull()
50
- expect(page.filePath).toContain("index.js")
51
- expect(page.params).toEqual({})
52
- })
53
-
54
- it("should resolve static page", async () => {
55
- const page = await resolvePage("/about", PAGES_DIR)
56
- expect(page).not.toBeNull()
57
- expect(page.filePath).toContain("about.js")
58
- expect(page.params).toEqual({})
59
- })
60
-
61
- it("should resolve dynamic page with params", async () => {
62
- const page = await resolvePage("/posts/hello-world", PAGES_DIR)
63
- expect(page).not.toBeNull()
64
- expect(page.filePath).toContain("posts")
65
- expect(page.params).toEqual({ slug: "hello-world" })
66
- })
67
-
68
- it("should resolve catch-all page", async () => {
69
- const page = await resolvePage("/api/v1/users", PAGES_DIR)
70
- expect(page).not.toBeNull()
71
- expect(page.filePath).toContain("api")
72
- expect(page.params).toEqual({ path: "v1/users" })
73
- })
74
-
75
- it("should return null for non-matching url", async () => {
76
- // Since we have a catch-all /api/*, /api/foo matches.
77
- // But /foo doesn't match anything except maybe if we had a root catch-all.
78
- // We don't have a root catch-all, just /api/*.
79
- // Wait, /blog/:slug matches /blog/foo.
80
- // /posts/:id matches /posts/1.
81
- // /about matches /about.
82
- // / matches /.
83
- // So /foo should return null.
84
- const page = await resolvePage("/foo", PAGES_DIR)
85
- expect(page).toBeNull()
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()
42
+ expect(routes.length).toBeGreaterThanOrEqual(6)
92
43
  })
93
44
  })
94
45
 
@@ -116,6 +67,40 @@ describe("router", () => {
116
67
  expect(consoleSpy).toHaveBeenCalled()
117
68
  expect(consoleSpy.mock.calls[2][0]).toContain("has no staticPaths")
118
69
  })
70
+
71
+ it("should localize static and dynamic pages when i18n is enabled", async () => {
72
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
73
+ const i18n = {
74
+ defaultLocale: "en",
75
+ locales: ["en", "it"],
76
+ }
77
+ const pages = await getPages(PAGES_DIR, { i18n })
78
+
79
+ const rootPages = pages.filter((p) => p.pattern === "/")
80
+ expect(rootPages).toEqual(
81
+ expect.arrayContaining([
82
+ expect.objectContaining({ path: "/", locale: "en" }),
83
+ expect.objectContaining({ path: "/it", locale: "it" }),
84
+ ]),
85
+ )
86
+
87
+ const aboutPages = pages.filter((p) => p.pattern === "/about")
88
+ expect(aboutPages).toEqual(
89
+ expect.arrayContaining([
90
+ expect.objectContaining({ path: "/about", locale: "en" }),
91
+ expect.objectContaining({ path: "/it/about", locale: "it" }),
92
+ ]),
93
+ )
94
+
95
+ expect(
96
+ pages.some(
97
+ (p) =>
98
+ p.pattern === "/posts/:slug" && p.path.startsWith("/it/posts/"),
99
+ ),
100
+ ).toBe(true)
101
+
102
+ expect(consoleSpy).toHaveBeenCalled()
103
+ })
119
104
  })
120
105
 
121
106
  describe("matchRoute", () => {
@@ -4,50 +4,89 @@
4
4
  *
5
5
  * @param {Object} store - The server-side store instance containing the initial state.
6
6
  * @param {Array<Object>} pages - List of page objects to generate routes for.
7
+ * @param {Object} [options] - Runtime options.
7
8
  * @returns {string} The generated JavaScript code for the client entry point.
8
9
  */
9
- export function generateApp(store, pages) {
10
- // Collect all unique page modules and their exports
11
- const routes = pages
12
- .filter((page, index) => {
13
- return pages.findIndex((p) => p.pattern === page.pattern) === index
14
- })
15
- .map(
16
- (page) =>
17
- ` "${page.pattern}": () => import("@/pages/${page.modulePath}")`,
10
+ export function generateApp(store, pages, options = {}) {
11
+ const i18n = options.i18n || inferI18nFromPages(pages)
12
+ const isDev = Boolean(options.isDev)
13
+
14
+ // Build client route map, including localized patterns (e.g. /it/about).
15
+ const routesByPattern = new Map()
16
+ for (const page of pages) {
17
+ const routePattern = getClientRoutePattern(page)
18
+ if (routesByPattern.has(routePattern)) continue
19
+ routesByPattern.set(
20
+ routePattern,
21
+ ` "${routePattern}": () => import("@/pages/${page.modulePath}")`,
18
22
  )
23
+ }
24
+ const routes = [...routesByPattern.values()]
19
25
 
20
26
  return `import { createDevtools, createStore, mount } from "@inglorious/web"
21
27
  import { getRoute, router, setRoutes } from "@inglorious/web/router"
28
+ import { getLocaleFromPath } from "@inglorious/ssx/i18n"
29
+
30
+ const normalizePathname = (path = "/") => path.split("?")[0].split("#")[0]
31
+ const normalizeRoutePath = (path = "/") => {
32
+ const pathname = normalizePathname(path)
33
+ if (pathname.length > 1 && pathname.endsWith("/")) {
34
+ return pathname.slice(0, -1)
35
+ }
36
+ return pathname
37
+ }
22
38
 
23
39
  const pages = ${JSON.stringify(
24
- pages.map(({ pattern, path, moduleName }) => ({
40
+ pages.map(({ pattern, path, moduleName, locale }) => ({
25
41
  pattern,
26
42
  path,
27
43
  moduleName,
44
+ locale,
28
45
  })),
29
46
  null,
30
47
  2,
31
48
  )}
32
- const path = window.location.pathname + window.location.search + window.location.hash
33
- const page = pages.find((page) => page.path === path)
49
+ const path = normalizeRoutePath(window.location.pathname)
50
+ const page = pages.find((page) => normalizeRoutePath(page.path) === path)
34
51
 
35
52
  const types = { router }
36
53
 
54
+ const i18n = ${JSON.stringify(i18n, null, 2)}
55
+ const isDev = ${JSON.stringify(isDev)}
56
+
37
57
  const entities = {
38
58
  router: {
39
59
  type: "router",
40
- path: page.path,
41
- route: page.moduleName,
60
+ path,
61
+ route: page?.moduleName,
62
+ },
63
+ i18n: {
64
+ type: "i18n",
65
+ ...i18n,
42
66
  },
43
67
  ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
44
68
  }
45
69
 
46
70
  const middlewares = []
47
- if (import.meta.env.DEV) {
71
+ if (isDev) {
48
72
  middlewares.push(createDevtools().middleware)
49
73
  }
50
74
 
75
+ const systems = []
76
+ if (i18n.defaultLocale && i18n.locales?.length) {
77
+ systems.push({
78
+ routeChange(state, payload) {
79
+ const routeType = payload?.route
80
+ if (!routeType) return
81
+
82
+ const entity = state[routeType]
83
+ if (!entity) return
84
+
85
+ entity.locale = getLocaleFromPath(payload.path, i18n)
86
+ },
87
+ })
88
+ }
89
+
51
90
  setRoutes({
52
91
  ${routes.join(",\n")}
53
92
  })
@@ -56,7 +95,7 @@ const module = await getRoute(page.pattern)()
56
95
  const type = module[page.moduleName]
57
96
  types[page.moduleName] = type
58
97
 
59
- const store = createStore({ types, entities, middlewares, autoCreateEntities: true })
98
+ const store = createStore({ types, entities, middlewares, systems, autoCreateEntities: true })
60
99
 
61
100
  const root = document.getElementById("root")
62
101
 
@@ -66,3 +105,39 @@ mount(store, (api) => {
66
105
  }, root)
67
106
  `
68
107
  }
108
+
109
+ function inferI18nFromPages(pages = []) {
110
+ const locales = [...new Set(pages.map((page) => page.locale).filter(Boolean))]
111
+ if (!locales.length) return {}
112
+
113
+ const defaultLocale =
114
+ locales.find((locale) =>
115
+ pages.some((page) => {
116
+ if (page.locale !== locale) return false
117
+ const localePrefix = `/${locale}`
118
+ return (
119
+ page.path === "/" ||
120
+ !(
121
+ page.path === localePrefix ||
122
+ page.path.startsWith(`${localePrefix}/`)
123
+ )
124
+ )
125
+ }),
126
+ ) || locales[0]
127
+
128
+ return { defaultLocale, locales }
129
+ }
130
+
131
+ function getClientRoutePattern(page) {
132
+ const { pattern = "/", path = "", locale } = page
133
+ if (!locale) return pattern
134
+
135
+ const localePrefix = `/${locale}`
136
+ const isLocalePrefixedPath =
137
+ path === localePrefix || path.startsWith(`${localePrefix}/`)
138
+
139
+ if (!isLocalePrefixedPath) return pattern
140
+ if (pattern === "/") return localePrefix
141
+
142
+ return `${localePrefix}${pattern}`
143
+ }
@@ -64,4 +64,42 @@ describe("generateApp", () => {
64
64
 
65
65
  expect(app).toMatchSnapshot()
66
66
  })
67
+
68
+ it("should include localized client routes when i18n pages are provided", async () => {
69
+ const page = {
70
+ pattern: "/hello",
71
+ path: "/hello",
72
+ modulePath: "hello.js",
73
+ filePath: path.join(PAGES_DIR, "hello.js"),
74
+ moduleName: "hello",
75
+ locale: "en",
76
+ }
77
+ const localizedPages = [
78
+ page,
79
+ { ...page, path: "/it/hello", locale: "it" },
80
+ { ...page, path: "/pt/hello", locale: "pt" },
81
+ ]
82
+ const store = await generateStore([page], { rootDir: ROOT_DIR })
83
+
84
+ const app = generateApp(store, localizedPages)
85
+
86
+ expect(app).toContain(`"/hello": () => import("@/pages/hello.js")`)
87
+ expect(app).toContain(`"/it/hello": () => import("@/pages/hello.js")`)
88
+ expect(app).toContain(`"/pt/hello": () => import("@/pages/hello.js")`)
89
+ expect(app).toContain(`const isDev = false`)
90
+ expect(app).toContain(
91
+ `import { getLocaleFromPath } from "@inglorious/ssx/i18n"`,
92
+ )
93
+ expect(app).toContain(`const systems = []`)
94
+ expect(app).toContain(`routeChange(state, payload)`)
95
+ expect(app).toContain(
96
+ `entity.locale = getLocaleFromPath(payload.path, i18n)`,
97
+ )
98
+ expect(app).toContain(
99
+ `const path = normalizeRoutePath(window.location.pathname)`,
100
+ )
101
+ expect(app).toContain(
102
+ `const page = pages.find((page) => normalizeRoutePath(page.path) === path)`,
103
+ )
104
+ })
67
105
  })
@@ -0,0 +1,11 @@
1
+ export function getLocaleFromPath(
2
+ pathname,
3
+ { defaultLocale = "en", locales = [] } = {},
4
+ ) {
5
+ const [pathOnly] = pathname.split("?")
6
+ const [cleanPath] = pathOnly.split("#")
7
+ const first = cleanPath.split("/").filter(Boolean)[0]
8
+
9
+ if (first && locales.includes(first)) return first
10
+ return defaultLocale
11
+ }
@@ -0,0 +1,6 @@
1
+ export interface I18nConfig {
2
+ defaultLocale: string
3
+ locales: string[]
4
+ }
5
+
6
+ export function getLocaleFromPath(pathname: string, config: I18nConfig): string