@inglorious/ssx 1.6.5 → 1.7.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.
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: {
@@ -660,7 +721,7 @@ await build({
660
721
  outDir: "dist",
661
722
  configFile: "site.config.js",
662
723
  incremental: true,
663
- clean: false,
724
+ force: false,
664
725
  })
665
726
  ```
666
727
 
@@ -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.1",
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"
@@ -27,12 +28,15 @@ import { createViteConfig } from "./vite-config.js"
27
28
  * @param {string} [options.rootDir="src"] - Source directory.
28
29
  * @param {string} [options.outDir="dist"] - Output directory.
29
30
  * @param {boolean} [options.incremental=true] - Whether to use incremental builds.
30
- * @param {boolean} [options.clean=false] - Whether to clean the output directory before building.
31
+ * @param {boolean} [options.force=false] - Whether to force a clean output directory before building.
31
32
  * @param {Object} [options.sitemap] - Sitemap configuration.
32
33
  * @param {Object} [options.rss] - RSS configuration.
33
34
  * @returns {Promise<{changed: number, skipped: number}>} Build statistics.
34
35
  */
35
36
  export async function build(options = {}) {
37
+ const previousNodeEnv = process.env.NODE_ENV
38
+ process.env.NODE_ENV = "production"
39
+
36
40
  const config = await loadConfig(options)
37
41
 
38
42
  const mergedOptions = { ...config, ...options }
@@ -40,7 +44,7 @@ export async function build(options = {}) {
40
44
  rootDir = ".",
41
45
  outDir = "dist",
42
46
  incremental = true,
43
- clean = false,
47
+ force = false,
44
48
  sitemap,
45
49
  rss,
46
50
  } = mergedOptions
@@ -52,20 +56,21 @@ export async function build(options = {}) {
52
56
  // Create a temporary Vite server to load modules (supports TS)
53
57
  const vite = await createServer({
54
58
  ...createViteConfig(mergedOptions),
59
+ mode: "production",
55
60
  server: { middlewareMode: true, hmr: false },
56
61
  appType: "custom",
57
62
  })
58
63
  const loader = (p) => vite.ssrLoadModule(p)
59
64
 
60
65
  // 0. Get all pages to build (Fail fast if source is broken)
61
- const allPages = await getPages(pagesDir, loader)
66
+ const allPages = await getPages(pagesDir, mergedOptions, loader)
62
67
  console.log(`📄 Found ${allPages.length} pages\n`)
63
68
 
64
69
  // Load previous build manifest
65
- const manifest = incremental && !clean ? await loadManifest(outDir) : null
70
+ const manifest = incremental && !force ? await loadManifest(outDir) : null
66
71
 
67
72
  // 1. Clean and create output directory
68
- if (clean || !manifest) {
73
+ if (force || !manifest) {
69
74
  // Clean output directory if forced or first build
70
75
  await fs.rm(outDir, { recursive: true, force: true })
71
76
  await fs.mkdir(outDir, { recursive: true })
@@ -75,15 +80,21 @@ export async function build(options = {}) {
75
80
  }
76
81
 
77
82
  // 2. Copy public assets before generating pages (could be useful if need to read `public/data.json`)
78
- await copyPublicDir(options)
83
+ await copyPublicDir(mergedOptions)
79
84
 
80
85
  // Determine which pages need rebuilding
81
86
  const entitiesHash = await hashEntities(rootDir)
87
+ const runtimeHash = await hashRuntime()
82
88
  let pagesToChange = allPages
83
89
  let pagesToSkip = []
84
90
 
85
91
  if (manifest) {
86
- const result = await determineRebuildPages(allPages, manifest, entitiesHash)
92
+ const result = await determineRebuildPages(
93
+ allPages,
94
+ manifest,
95
+ entitiesHash,
96
+ runtimeHash,
97
+ )
87
98
  pagesToChange = result.pagesToBuild
88
99
  pagesToSkip = result.pagesToSkip
89
100
 
@@ -129,7 +140,7 @@ export async function build(options = {}) {
129
140
  // 8. Always regenerate client-side JavaScript (it's cheap and ensures consistency)
130
141
  console.log("\n📝 Generating client scripts...\n")
131
142
 
132
- const app = generateApp(store, allPages)
143
+ const app = generateApp(allPages, { ...mergedOptions, isDev: false })
133
144
  await fs.writeFile(path.join(outDir, "main.js"), app, "utf-8")
134
145
  console.log(` ✓ main.js\n`)
135
146
 
@@ -147,7 +158,10 @@ export async function build(options = {}) {
147
158
 
148
159
  // 11. Bundle with Vite
149
160
  console.log("\n📦 Bundling with Vite...\n")
150
- const viteConfig = createViteConfig(mergedOptions)
161
+ const viteConfig = {
162
+ ...createViteConfig(mergedOptions),
163
+ mode: "production",
164
+ }
151
165
  await viteBuild(viteConfig)
152
166
 
153
167
  await vite.close()
@@ -156,16 +170,28 @@ export async function build(options = {}) {
156
170
 
157
171
  // 13. Save manifest for next build
158
172
  if (incremental) {
159
- const newManifest = await createManifest(allGeneratedPages, entitiesHash)
173
+ const newManifest = await createManifest(
174
+ allGeneratedPages,
175
+ entitiesHash,
176
+ runtimeHash,
177
+ )
160
178
  await saveManifest(outDir, newManifest)
161
179
  }
162
180
 
163
181
  console.log("\n✨ Build complete!\n")
164
182
 
165
- return {
183
+ const result = {
166
184
  changed: changedPages.length,
167
185
  skipped: skippedPages.length,
168
186
  }
187
+
188
+ if (previousNodeEnv == null) {
189
+ delete process.env.NODE_ENV
190
+ } else {
191
+ process.env.NODE_ENV = previousNodeEnv
192
+ }
193
+
194
+ return result
169
195
  }
170
196
 
171
197
  /**
@@ -3,6 +3,14 @@ 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
+ "./vite-config.js",
10
+ "../utils/i18n.js",
11
+ "../router/index.js",
12
+ "../render/index.js",
13
+ ]
6
14
 
7
15
  /**
8
16
  * Loads the build manifest from the previous build.
@@ -18,7 +26,7 @@ export async function loadManifest(outDir) {
18
26
  return JSON.parse(content)
19
27
  } catch {
20
28
  // No manifest exists (first build or clean build)
21
- return { pages: {}, entities: null, buildTime: null }
29
+ return { pages: {}, entities: null, runtime: null, buildTime: null }
22
30
  }
23
31
  }
24
32
 
@@ -61,6 +69,25 @@ export async function hashEntities(rootDir) {
61
69
  return await hashFile(entitiesPath)
62
70
  }
63
71
 
72
+ /**
73
+ * Computes a hash for SSX runtime internals.
74
+ * When this changes, page HTML should be regenerated even if source pages did not change.
75
+ *
76
+ * @returns {Promise<string>} Hash of runtime internals.
77
+ */
78
+ export async function hashRuntime() {
79
+ const root = import.meta.dirname
80
+ const contents = await Promise.all(
81
+ RUNTIME_FILES.map(async (relativePath) => {
82
+ const filePath = path.resolve(root, relativePath)
83
+ const content = await fs.readFile(filePath, "utf-8")
84
+ return `${relativePath}:${content}`
85
+ }),
86
+ )
87
+
88
+ return crypto.createHash("md5").update(contents.join("\n")).digest("hex")
89
+ }
90
+
64
91
  /**
65
92
  * Determines which pages need to be rebuilt.
66
93
  * Compares current file hashes against the manifest.
@@ -68,15 +95,26 @@ export async function hashEntities(rootDir) {
68
95
  * @param {Array<Object>} pages - All pages to potentially build.
69
96
  * @param {Object} manifest - Previous build manifest.
70
97
  * @param {string} entitiesHash - Current entities hash.
98
+ * @param {string} runtimeHash - Current SSX runtime hash.
71
99
  * @returns {Promise<{pagesToBuild: Array<Object>, pagesToSkip: Array<Object>}>} Object with pagesToBuild and pagesSkipped.
72
100
  */
73
- export async function determineRebuildPages(pages, manifest, entitiesHash) {
101
+ export async function determineRebuildPages(
102
+ pages,
103
+ manifest,
104
+ entitiesHash,
105
+ runtimeHash,
106
+ ) {
74
107
  // If entities changed, rebuild all pages
75
108
  if (manifest.entities !== entitiesHash) {
76
109
  console.log("📦 Entities changed, rebuilding all pages\n")
77
110
  return { pagesToBuild: pages, pagesToSkip: [] }
78
111
  }
79
112
 
113
+ if (manifest.runtime !== runtimeHash) {
114
+ console.log("🔁 SSX runtime changed, rebuilding all pages\n")
115
+ return { pagesToBuild: pages, pagesToSkip: [] }
116
+ }
117
+
80
118
  const pagesToBuild = []
81
119
  const pagesToSkip = []
82
120
 
@@ -99,9 +137,10 @@ export async function determineRebuildPages(pages, manifest, entitiesHash) {
99
137
  *
100
138
  * @param {Array<Object>} renderedPages - All rendered pages.
101
139
  * @param {string} entitiesHash - Hash of entities file.
140
+ * @param {string} runtimeHash - Hash of SSX runtime internals.
102
141
  * @returns {Promise<Object>} New manifest.
103
142
  */
104
- export async function createManifest(renderedPages, entitiesHash) {
143
+ export async function createManifest(renderedPages, entitiesHash, runtimeHash) {
105
144
  const pages = {}
106
145
 
107
146
  for (const page of renderedPages) {
@@ -115,6 +154,7 @@ export async function createManifest(renderedPages, entitiesHash) {
115
154
  return {
116
155
  pages,
117
156
  entities: entitiesHash,
157
+ runtime: runtimeHash,
118
158
  buildTime: new Date().toISOString(),
119
159
  }
120
160
  }
@@ -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
  })
@@ -1,5 +1,6 @@
1
1
  import path from "node:path"
2
2
 
3
+ import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals"
3
4
  import { mergeConfig } from "vite"
4
5
  import { ViteImageOptimizer } from "vite-plugin-image-optimizer"
5
6
 
@@ -21,6 +22,7 @@ export function createViteConfig(options = {}) {
21
22
  root: process.cwd(),
22
23
  publicDir: publicDir,
23
24
  plugins: [
25
+ minifyTemplateLiterals(),
24
26
  ViteImageOptimizer({
25
27
  // Options can be overridden by the user in site.config.js via the `vite` property
26
28
  }),
@@ -44,9 +46,6 @@ export function createViteConfig(options = {}) {
44
46
  }
45
47
  },
46
48
  },
47
- plugins: [
48
- // minifyTemplateLiterals(), // TODO: minification breaks hydration. The footprint difference is minimal after all
49
- ],
50
49
  },
51
50
  },
52
51
  resolve: {
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
@@ -18,6 +18,7 @@ import { layout as defaultLayout } from "./layout.js"
18
18
  * @param {boolean} [options.wrap=false] - Whether to wrap the output in a full HTML document.
19
19
  * @param {Function} [options.layout] - Custom layout function.
20
20
  * @param {boolean} [options.stripLitMarkers=false] - Whether to remove Lit hydration markers (for static output).
21
+ * @param {Object} [options.ssxEntity] - Per-page entity state for client hydration.
21
22
  * @returns {Promise<string>} The generated HTML string.
22
23
  */
23
24
  export async function toHTML(store, renderFn, options = {}) {
@@ -38,7 +39,16 @@ export async function toHTML(store, renderFn, options = {}) {
38
39
  if (!options.wrap) return finalHTML
39
40
 
40
41
  const layout = options.layout ?? defaultLayout
41
- return layout(finalHTML, options)
42
+ let html = layout(finalHTML, options)
43
+
44
+ if (options.ssxEntity) {
45
+ html = html.replace(
46
+ /<body[^>]*>/,
47
+ `$&<script type="application/json" id="__SSX_ENTITY__">${JSON.stringify(options.ssxEntity)}</script>`,
48
+ )
49
+ }
50
+
51
+ return html
42
52
  }
43
53
 
44
54
  /**
@@ -52,5 +52,6 @@ export async function renderPage(store, page, entity, options = {}) {
52
52
  styles,
53
53
  head,
54
54
  scripts,
55
+ ssxEntity: { [moduleName]: entity },
55
56
  })
56
57
  }
@@ -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", () => {
@@ -2,52 +2,92 @@
2
2
  * Generates the client-side entry point script.
3
3
  * This script hydrates the store with the initial state (entities) and sets up the router.
4
4
  *
5
- * @param {Object} store - The server-side store instance containing the initial state.
6
5
  * @param {Array<Object>} pages - List of page objects to generate routes for.
6
+ * @param {Object} [options] - Runtime options.
7
7
  * @returns {string} The generated JavaScript code for the client entry point.
8
8
  */
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}")`,
9
+ export function generateApp(pages, options = {}) {
10
+ const i18n = options.i18n || inferI18nFromPages(pages)
11
+ const isDev = Boolean(options.isDev)
12
+
13
+ // Build client route map, including localized patterns (e.g. /it/about).
14
+ const routesByPattern = new Map()
15
+ for (const page of pages) {
16
+ const routePattern = getClientRoutePattern(page)
17
+ if (routesByPattern.has(routePattern)) continue
18
+ routesByPattern.set(
19
+ routePattern,
20
+ ` "${routePattern}": () => import("@/pages/${page.modulePath}")`,
18
21
  )
22
+ }
23
+ const routes = [...routesByPattern.values()]
19
24
 
20
25
  return `import { createDevtools, createStore, mount } from "@inglorious/web"
21
26
  import { getRoute, router, setRoutes } from "@inglorious/web/router"
27
+ import { getLocaleFromPath } from "@inglorious/ssx/i18n"
28
+
29
+ const normalizePathname = (path = "/") => path.split("?")[0].split("#")[0]
30
+ const normalizeRoutePath = (path = "/") => {
31
+ const pathname = normalizePathname(path)
32
+ if (pathname.length > 1 && pathname.endsWith("/")) {
33
+ return pathname.slice(0, -1)
34
+ }
35
+ return pathname
36
+ }
22
37
 
23
38
  const pages = ${JSON.stringify(
24
- pages.map(({ pattern, path, moduleName }) => ({
39
+ pages.map(({ pattern, path, moduleName, locale }) => ({
25
40
  pattern,
26
41
  path,
27
42
  moduleName,
43
+ locale,
28
44
  })),
29
45
  null,
30
46
  2,
31
47
  )}
32
- const path = window.location.pathname + window.location.search + window.location.hash
33
- const page = pages.find((page) => page.path === path)
48
+ const path = normalizeRoutePath(window.location.pathname)
49
+ const page = pages.find((page) => normalizeRoutePath(page.path) === path)
34
50
 
35
51
  const types = { router }
36
52
 
53
+ const i18n = ${JSON.stringify(i18n, null, 2)}
54
+ const isDev = ${JSON.stringify(isDev)}
55
+
56
+ const ssxEntity = JSON.parse(document.getElementById("__SSX_ENTITY__").textContent)
57
+
37
58
  const entities = {
38
59
  router: {
39
60
  type: "router",
40
- path: page.path,
41
- route: page.moduleName,
61
+ path,
62
+ route: page?.moduleName,
42
63
  },
43
- ${JSON.stringify(store.getState(), null, 2).slice(1, -1)}
64
+ i18n: {
65
+ type: "i18n",
66
+ ...i18n,
67
+ },
68
+ ...ssxEntity,
44
69
  }
45
70
 
46
71
  const middlewares = []
47
- if (import.meta.env.DEV) {
72
+ if (isDev) {
48
73
  middlewares.push(createDevtools().middleware)
49
74
  }
50
75
 
76
+ const systems = []
77
+ if (i18n.defaultLocale && i18n.locales?.length) {
78
+ systems.push({
79
+ routeChange(state, payload) {
80
+ const routeType = payload?.route
81
+ if (!routeType) return
82
+
83
+ const entity = state[routeType]
84
+ if (!entity) return
85
+
86
+ entity.locale = getLocaleFromPath(payload.path, i18n)
87
+ },
88
+ })
89
+ }
90
+
51
91
  setRoutes({
52
92
  ${routes.join(",\n")}
53
93
  })
@@ -56,7 +96,7 @@ const module = await getRoute(page.pattern)()
56
96
  const type = module[page.moduleName]
57
97
  types[page.moduleName] = type
58
98
 
59
- const store = createStore({ types, entities, middlewares, autoCreateEntities: true })
99
+ const store = createStore({ types, entities, middlewares, systems, autoCreateEntities: true })
60
100
 
61
101
  const root = document.getElementById("root")
62
102
 
@@ -66,3 +106,39 @@ mount(store, (api) => {
66
106
  }, root)
67
107
  `
68
108
  }
109
+
110
+ function inferI18nFromPages(pages = []) {
111
+ const locales = [...new Set(pages.map((page) => page.locale).filter(Boolean))]
112
+ if (!locales.length) return {}
113
+
114
+ const defaultLocale =
115
+ locales.find((locale) =>
116
+ pages.some((page) => {
117
+ if (page.locale !== locale) return false
118
+ const localePrefix = `/${locale}`
119
+ return (
120
+ page.path === "/" ||
121
+ !(
122
+ page.path === localePrefix ||
123
+ page.path.startsWith(`${localePrefix}/`)
124
+ )
125
+ )
126
+ }),
127
+ ) || locales[0]
128
+
129
+ return { defaultLocale, locales }
130
+ }
131
+
132
+ function getClientRoutePattern(page) {
133
+ const { pattern = "/", path = "", locale } = page
134
+ if (!locale) return pattern
135
+
136
+ const localePrefix = `/${locale}`
137
+ const isLocalePrefixedPath =
138
+ path === localePrefix || path.startsWith(`${localePrefix}/`)
139
+
140
+ if (!isLocalePrefixedPath) return pattern
141
+ if (pattern === "/") return localePrefix
142
+
143
+ return `${localePrefix}${pattern}`
144
+ }
@@ -2,7 +2,6 @@ import path from "node:path"
2
2
 
3
3
  import { describe, expect, it } from "vitest"
4
4
 
5
- import { generateStore } from "../store"
6
5
  import { generateApp } from "./app"
7
6
 
8
7
  const ROOT_DIR = path.join(import.meta.dirname, "..", "__fixtures__")
@@ -16,9 +15,8 @@ describe("generateApp", () => {
16
15
  modulePath: "index.js",
17
16
  filePath: PAGES_DIR,
18
17
  }
19
- const store = await generateStore([page], { rootDir: ROOT_DIR })
20
18
 
21
- const app = generateApp(store, [page])
19
+ const app = generateApp([page])
22
20
 
23
21
  expect(app).toMatchSnapshot()
24
22
  })
@@ -30,9 +28,8 @@ describe("generateApp", () => {
30
28
  modulePath: "about.js",
31
29
  filePath: path.join(PAGES_DIR, "about.js"),
32
30
  }
33
- const store = await generateStore([page], { rootDir: ROOT_DIR })
34
31
 
35
- const app = generateApp(store, [page])
32
+ const app = generateApp([page])
36
33
 
37
34
  expect(app).toMatchSnapshot()
38
35
  })
@@ -44,9 +41,8 @@ describe("generateApp", () => {
44
41
  modulePath: "blog.js",
45
42
  filePath: path.join(PAGES_DIR, "blog.js"),
46
43
  }
47
- const store = await generateStore([page], { rootDir: ROOT_DIR })
48
44
 
49
- const app = generateApp(store, [page])
45
+ const app = generateApp([page])
50
46
 
51
47
  expect(app).toMatchSnapshot()
52
48
  })
@@ -58,10 +54,46 @@ describe("generateApp", () => {
58
54
  modulePath: "post.js",
59
55
  filePath: path.join(PAGES_DIR, "posts", "_slug.js"),
60
56
  }
61
- const store = await generateStore([page], { rootDir: ROOT_DIR })
62
57
 
63
- const app = generateApp(store, [page])
58
+ const app = generateApp([page])
64
59
 
65
60
  expect(app).toMatchSnapshot()
66
61
  })
62
+
63
+ it("should include localized client routes when i18n pages are provided", async () => {
64
+ const page = {
65
+ pattern: "/hello",
66
+ path: "/hello",
67
+ modulePath: "hello.js",
68
+ filePath: path.join(PAGES_DIR, "hello.js"),
69
+ moduleName: "hello",
70
+ locale: "en",
71
+ }
72
+ const localizedPages = [
73
+ page,
74
+ { ...page, path: "/it/hello", locale: "it" },
75
+ { ...page, path: "/pt/hello", locale: "pt" },
76
+ ]
77
+
78
+ const app = generateApp(localizedPages)
79
+
80
+ expect(app).toContain(`"/hello": () => import("@/pages/hello.js")`)
81
+ expect(app).toContain(`"/it/hello": () => import("@/pages/hello.js")`)
82
+ expect(app).toContain(`"/pt/hello": () => import("@/pages/hello.js")`)
83
+ expect(app).toContain(`const isDev = false`)
84
+ expect(app).toContain(
85
+ `import { getLocaleFromPath } from "@inglorious/ssx/i18n"`,
86
+ )
87
+ expect(app).toContain(`const systems = []`)
88
+ expect(app).toContain(`routeChange(state, payload)`)
89
+ expect(app).toContain(
90
+ `entity.locale = getLocaleFromPath(payload.path, i18n)`,
91
+ )
92
+ expect(app).toContain(
93
+ `const path = normalizeRoutePath(window.location.pathname)`,
94
+ )
95
+ expect(app).toContain(
96
+ `const page = pages.find((page) => normalizeRoutePath(page.path) === path)`,
97
+ )
98
+ })
67
99
  })
@@ -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
+ }
@@ -3,39 +3,9 @@ import katex from "katex"
3
3
  import MarkdownIt from "markdown-it"
4
4
  import texmath from "markdown-it-texmath"
5
5
 
6
- export function createMarkdownRenderer() {
7
- const md = new MarkdownIt({
8
- html: true,
9
- linkify: true,
10
- typographer: true,
11
- highlight: (str, lang) => {
12
- if (lang === "mermaid") {
13
- return `<div class="mermaid">${str}</div>`
14
- }
15
- if (lang && hljs.getLanguage(lang)) {
16
- try {
17
- return hljs.highlight(str, { language: lang }).value
18
- } catch (err) {
19
- console.error(err)
20
- }
21
- }
22
- return "" // use external default escaping
23
- },
24
- })
25
-
26
- // Add LaTeX support
27
- md.use(texmath, {
28
- engine: katex,
29
- delimiters: "dollars",
30
- katexOptions: { macros: { "\\RR": "\\mathbb{R}" } },
31
- })
32
-
33
- return md
34
- }
35
-
36
6
  export function renderMarkdown(markdown) {
37
7
  const md = createMarkdownRenderer()
38
- // Simple frontmatter stripping for runtime rendering (avoids Buffer/gray-matter on client)
8
+ // gray-matter gives an error on the client, so we are going to use a simpler way to extract content
39
9
  const content = markdown.replace(/^---[\s\S]*?---\n/, "")
40
10
  return md.render(content)
41
11
  }
@@ -50,6 +20,7 @@ export function markdownPlugin(options = {}) {
50
20
  async transform(code, id) {
51
21
  if (!id.endsWith(".md")) return
52
22
 
23
+ // prevents importing gray-matter on the client
53
24
  const matter = (await import("gray-matter")).default
54
25
  const { content, data } = matter(code)
55
26
  const htmlContent = md.render(content)
@@ -58,18 +29,14 @@ export function markdownPlugin(options = {}) {
58
29
 
59
30
  let mermaidCode = ""
60
31
  if (hasMermaid) {
61
- mermaidCode = `
62
- import mermaid from "mermaid"
63
- if (typeof window !== "undefined") {
64
- mermaid.initialize({ startOnLoad: false })
65
- }
66
- `
32
+ mermaidCode = `import mermaid from "mermaid"`
67
33
  }
68
34
 
69
35
  return `
70
- import { html, unsafeHTML } from "@inglorious/web"
71
36
  import "katex/dist/katex.min.css"
72
37
  import "highlight.js/styles/${theme}.css"
38
+
39
+ import { html, unsafeHTML } from "@inglorious/web"
73
40
  ${mermaidCode}
74
41
 
75
42
  export const metadata = ${JSON.stringify(data)}
@@ -79,7 +46,7 @@ export function markdownPlugin(options = {}) {
79
46
  if (typeof window !== "undefined" && ${hasMermaid}) {
80
47
  setTimeout(() => {
81
48
  mermaid.run({ querySelector: ".mermaid" })
82
- }, 0)
49
+ })
83
50
  }
84
51
  return html\`<div class="markdown-body">\${unsafeHTML(${JSON.stringify(htmlContent)})}</div>\`
85
52
  }
@@ -88,3 +55,48 @@ export function markdownPlugin(options = {}) {
88
55
  },
89
56
  }
90
57
  }
58
+
59
+ function createMarkdownRenderer() {
60
+ const md = new MarkdownIt({
61
+ html: true,
62
+ linkify: true,
63
+ typographer: true,
64
+ highlight: (str, lang) => {
65
+ if (lang && hljs.getLanguage(lang)) {
66
+ try {
67
+ return hljs.highlight(str, { language: lang }).value
68
+ } catch (err) {
69
+ console.error(err)
70
+ }
71
+ }
72
+ return "" // use external default escaping
73
+ },
74
+ })
75
+
76
+ // Add LaTeX support
77
+ md.use(texmath, {
78
+ engine: katex,
79
+ delimiters: "dollars",
80
+ katexOptions: { macros: { "\\RR": "\\mathbb{R}" } },
81
+ })
82
+
83
+ const defaultFence =
84
+ md.renderer.rules.fence?.bind(md.renderer.rules) ||
85
+ ((tokens, idx, options, env, self) =>
86
+ self.renderToken(tokens, idx, options, env, self))
87
+
88
+ // Render mermaid fences as standalone blocks (not nested in <pre><code>),
89
+ // avoiding invalid HTML that can cause hydration mismatches.
90
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
91
+ const token = tokens[idx]
92
+ const lang = token.info?.trim().split(/\s+/)[0]
93
+
94
+ if (lang === "mermaid") {
95
+ return `<div class="mermaid">${md.utils.escapeHtml(token.content)}</div>\n`
96
+ }
97
+
98
+ return defaultFence(tokens, idx, options, env, self)
99
+ }
100
+
101
+ return md
102
+ }
@@ -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