@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 +63 -2
- package/package.json +7 -3
- package/src/build/index.js +21 -6
- package/src/build/manifest.js +42 -3
- package/src/build/manifest.test.js +47 -4
- package/src/build/pages.js +4 -0
- package/src/build/pages.test.js +50 -0
- package/src/dev/index.js +6 -2
- package/src/router/index.js +23 -31
- package/src/router/router.test.js +36 -51
- package/src/scripts/app.js +91 -16
- package/src/scripts/app.test.js +38 -0
- package/src/utils/i18n.js +11 -0
- package/types/i18n.d.ts +6 -0
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
|
-
- [
|
|
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.
|
|
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.
|
|
64
|
+
"@inglorious/web": "4.3.0"
|
|
61
65
|
},
|
|
62
66
|
"devDependencies": {
|
|
63
67
|
"prettier": "^3.6.2",
|
package/src/build/index.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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 =
|
|
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(
|
|
170
|
+
const newManifest = await createManifest(
|
|
171
|
+
allGeneratedPages,
|
|
172
|
+
entitiesHash,
|
|
173
|
+
runtimeHash,
|
|
174
|
+
)
|
|
160
175
|
await saveManifest(outDir, newManifest)
|
|
161
176
|
}
|
|
162
177
|
|
package/src/build/manifest.js
CHANGED
|
@@ -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(
|
|
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({
|
|
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(
|
|
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(
|
|
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",
|
package/src/build/pages.js
CHANGED
|
@@ -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
|
}
|
package/src/build/pages.test.js
CHANGED
|
@@ -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
|
package/src/router/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
87
|
+
if (!i18n.locales?.length) {
|
|
88
|
+
pages.push(pageData)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
101
91
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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", () => {
|
package/src/scripts/app.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
41
|
-
route: page
|
|
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 (
|
|
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
|
+
}
|
package/src/scripts/app.test.js
CHANGED
|
@@ -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
|
+
}
|