@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 +64 -3
- package/package.json +7 -3
- package/src/build/index.js +37 -11
- package/src/build/manifest.js +43 -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/build/vite-config.js +2 -3
- package/src/dev/index.js +6 -2
- package/src/render/html.js +11 -1
- package/src/render/index.js +1 -0
- package/src/router/index.js +23 -31
- package/src/router/router.test.js +36 -51
- package/src/scripts/app.js +94 -18
- package/src/scripts/app.test.js +41 -9
- package/src/utils/i18n.js +11 -0
- package/src/utils/markdown.js +51 -39
- 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: {
|
|
@@ -660,7 +721,7 @@ await build({
|
|
|
660
721
|
outDir: "dist",
|
|
661
722
|
configFile: "site.config.js",
|
|
662
723
|
incremental: true,
|
|
663
|
-
|
|
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
|
-
- [
|
|
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.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.
|
|
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"
|
|
@@ -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.
|
|
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
|
-
|
|
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 && !
|
|
70
|
+
const manifest = incremental && !force ? await loadManifest(outDir) : null
|
|
66
71
|
|
|
67
72
|
// 1. Clean and create output directory
|
|
68
|
-
if (
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/src/build/manifest.js
CHANGED
|
@@ -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(
|
|
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({
|
|
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/build/vite-config.js
CHANGED
|
@@ -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
|
package/src/render/html.js
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/render/index.js
CHANGED
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
|
@@ -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(
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
41
|
-
route: page
|
|
61
|
+
path,
|
|
62
|
+
route: page?.moduleName,
|
|
42
63
|
},
|
|
43
|
-
|
|
64
|
+
i18n: {
|
|
65
|
+
type: "i18n",
|
|
66
|
+
...i18n,
|
|
67
|
+
},
|
|
68
|
+
...ssxEntity,
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
const middlewares = []
|
|
47
|
-
if (
|
|
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
|
+
}
|
package/src/scripts/app.test.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/utils/markdown.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
}
|
|
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
|
+
}
|