@finesoft/front 0.1.76 → 0.1.77
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# 4. Rendering & hydration
|
|
2
|
+
|
|
3
|
+
How a page travels from controller output to bytes on the wire, then back into a live browser app. This chapter covers SSR, CSR, prerender, and the `PrefetchedIntents` machinery that ties them together.
|
|
4
|
+
|
|
5
|
+
## The three modes side by side
|
|
6
|
+
|
|
7
|
+
| | SSR | CSR | Prerender |
|
|
8
|
+
| --------------------------- | -------------------------------- | -------------------------------- | -------------------------------- |
|
|
9
|
+
| When HTML is built | Per request, on the server | At build time (shell only) | At build time, per route |
|
|
10
|
+
| Initial body | Fully rendered | Empty `<div id="app"></div>` | Fully rendered |
|
|
11
|
+
| Initial fetch on hydration? | No (data in `PrefetchedIntents`) | Yes (controller runs in browser) | No (data in `PrefetchedIntents`) |
|
|
12
|
+
| TTFB | One controller execution | Near-zero | Static file serve |
|
|
13
|
+
| Personalization | Per-request OK | Best — runs entirely client-side | None (same HTML for everyone) |
|
|
14
|
+
| SEO | Good | Requires JS-aware crawlers | Best |
|
|
15
|
+
|
|
16
|
+
Mode is **per-route**. Mix freely.
|
|
17
|
+
|
|
18
|
+
## SSR pipeline
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Request URL
|
|
22
|
+
│
|
|
23
|
+
▼
|
|
24
|
+
Router.resolve() → RouteMatch
|
|
25
|
+
│
|
|
26
|
+
▼
|
|
27
|
+
beforeLoad guards → may rewrite (internal) / redirect / deny
|
|
28
|
+
│
|
|
29
|
+
▼
|
|
30
|
+
IntentDispatcher.dispatch() → Page
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
33
|
+
afterLoad guards → may redirect / deny / signal canonicalization
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
renderApp(page) → { html, head, css }
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
injectSSRContent() → final HTML with:
|
|
40
|
+
• rendered body in <!--ssr-->
|
|
41
|
+
• head fragment in <!--head-->
|
|
42
|
+
• serialized PrefetchedIntents in a <script> tag
|
|
43
|
+
• <html lang="..." dir="..."> attributes
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### SSR entry
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// src/ssr.ts
|
|
50
|
+
import { createSSRRender, serializeServerData } from "@finesoft/front";
|
|
51
|
+
import { createSSRApp } from "vue";
|
|
52
|
+
import { renderToString } from "vue/server-renderer";
|
|
53
|
+
import App from "./App.vue";
|
|
54
|
+
import { bootstrap } from "./bootstrap";
|
|
55
|
+
|
|
56
|
+
export const render = createSSRRender({
|
|
57
|
+
bootstrap,
|
|
58
|
+
getErrorPage: () => ({ kind: "error", title: "Something went wrong" }),
|
|
59
|
+
async renderApp(page) {
|
|
60
|
+
const app = createSSRApp(App, { page });
|
|
61
|
+
const html = await renderToString(app);
|
|
62
|
+
return {
|
|
63
|
+
html,
|
|
64
|
+
head: `<title>${escape(page.title)}</title>`,
|
|
65
|
+
css: "",
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export { serializeServerData };
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The Vite plugin and adapters call `render(url, options)` for you. You return `{ html, head, css }`; the framework handles injection and serialization.
|
|
74
|
+
|
|
75
|
+
### What `createSSRRender` does for you
|
|
76
|
+
|
|
77
|
+
- Runs `bootstrap()` once on the server (cached across requests in the same worker)
|
|
78
|
+
- Creates a request-scoped DI container per request
|
|
79
|
+
- Runs the middleware pipeline
|
|
80
|
+
- Calls your `renderApp()` to produce the body
|
|
81
|
+
- Serializes prefetched intent results into a `<script id="__finesoft_data__">` tag
|
|
82
|
+
- Sets `<html lang dir>` from the resolved locale
|
|
83
|
+
- Sets HTTP status from `deny()` / `redirect()` / `rewrite()` results
|
|
84
|
+
- Adds `Content-Location` header when `afterLoad` signaled a rewrite
|
|
85
|
+
|
|
86
|
+
## CSR (client-side render)
|
|
87
|
+
|
|
88
|
+
For routes marked `renderMode: "csr"`, the server returns a minimal shell:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<!doctype html>
|
|
92
|
+
<html lang="en">
|
|
93
|
+
<head>
|
|
94
|
+
<!-- head injected here -->
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div id="app"></div>
|
|
98
|
+
<!-- no PrefetchedIntents script — controller runs in browser -->
|
|
99
|
+
<script type="module" src="/src/main.ts"></script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The controller runs in the browser when `startBrowserApp()` triggers the first navigation. Use CSR for:
|
|
105
|
+
|
|
106
|
+
- Heavily personalized dashboards behind auth
|
|
107
|
+
- Pages where SEO doesn't matter
|
|
108
|
+
- Pages where server-side rendering cost outweighs the latency benefit
|
|
109
|
+
|
|
110
|
+
## Prerender (static + ISR)
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
{ path: "/about", intentId: "about", controller: new AboutController(), renderMode: "prerender" }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
At build time the framework:
|
|
117
|
+
|
|
118
|
+
1. Calls `controller.execute({}, container)` (path params from the static path)
|
|
119
|
+
2. Runs `renderApp()` to produce HTML
|
|
120
|
+
3. Writes `dist/about.html` to disk
|
|
121
|
+
|
|
122
|
+
The adapter serves these static files directly. No controller runs at request time.
|
|
123
|
+
|
|
124
|
+
### Incremental Static Regeneration (ISR)
|
|
125
|
+
|
|
126
|
+
The bundled server (`createServer`) and the preview server (`vp preview`) support cached on-demand regeneration. Configure via `finesoftFrontViteConfig`:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
finesoftFrontViteConfig({
|
|
130
|
+
ssr: { entry: "src/ssr.ts" },
|
|
131
|
+
isr: {
|
|
132
|
+
// routes that should regenerate on demand
|
|
133
|
+
routes: ["/blog/*"],
|
|
134
|
+
// cache TTL in seconds
|
|
135
|
+
ttl: 300,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The first request after expiry triggers a fresh render; concurrent requests get the stale version until the regeneration completes. See [server & deployment](./09-server-and-deployment.md#isr) for details.
|
|
141
|
+
|
|
142
|
+
## `PrefetchedIntents` — the SSR → CSR bridge
|
|
143
|
+
|
|
144
|
+
The crucial mechanic: **the same controller produces a page on the server, and the browser reuses that result without refetching.**
|
|
145
|
+
|
|
146
|
+
### How it works
|
|
147
|
+
|
|
148
|
+
1. SSR: controller runs, returns `Page`. The framework stores `(intentId, paramsKey) → Page` in a `PrefetchedIntents` map.
|
|
149
|
+
2. Render: the map is JSON-stringified into `<script id="__finesoft_data__">{...}</script>`.
|
|
150
|
+
3. Browser: `startBrowserApp` reads the script, calls `createPrefetchedIntentsFromDom()`, passes it to `Framework.create()`.
|
|
151
|
+
4. First navigation in the browser: `IntentDispatcher.dispatch()` checks the map by `(intentId, paramsKey)` — if hit, returns the cached `Page` directly without calling the controller.
|
|
152
|
+
|
|
153
|
+
### Stable key generation
|
|
154
|
+
|
|
155
|
+
The lookup key is generated from `intentId` + the **stable JSON stringification** of `params`. Object key order does not affect the key:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// These produce the same paramsKey:
|
|
159
|
+
dispatch({ intentId: "product", params: { id: "42", color: "red" } });
|
|
160
|
+
dispatch({ intentId: "product", params: { color: "red", id: "42" } });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If you write a controller that resolves the same logical request from different `params` shapes, factor it into a normalization step before dispatch.
|
|
164
|
+
|
|
165
|
+
### When the cache misses
|
|
166
|
+
|
|
167
|
+
- New navigation to an intent not prefetched on the server (e.g., dynamic route the user clicked)
|
|
168
|
+
- Stale cache after `PrefetchedIntents.invalidate(intentId, params)`
|
|
169
|
+
- Browser-side mutation guards (custom)
|
|
170
|
+
|
|
171
|
+
A miss falls through to the regular dispatcher path — `execute()` runs in the browser.
|
|
172
|
+
|
|
173
|
+
## Hydration step-by-step
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
Server Browser
|
|
177
|
+
────── ───────
|
|
178
|
+
bootstrap(framework)
|
|
179
|
+
▼ │
|
|
180
|
+
controller.execute() │
|
|
181
|
+
▼ │
|
|
182
|
+
Page A │
|
|
183
|
+
▼ │
|
|
184
|
+
serialize → <script> │
|
|
185
|
+
▼ │
|
|
186
|
+
HTML response ────────────────▶ Receive HTML
|
|
187
|
+
▼
|
|
188
|
+
createPrefetchedIntentsFromDom()
|
|
189
|
+
▼
|
|
190
|
+
Framework.create({ prefetchedIntents })
|
|
191
|
+
▼
|
|
192
|
+
bootstrap(framework) ← same code, same routes
|
|
193
|
+
▼
|
|
194
|
+
dispatch(currentIntent)
|
|
195
|
+
▼
|
|
196
|
+
Cache hit → Page A ← no refetch
|
|
197
|
+
▼
|
|
198
|
+
mount(app)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The bootstrap runs twice — once on each side — with identical inputs. This is what guarantees the browser-side initial route matches the server-rendered HTML.
|
|
202
|
+
|
|
203
|
+
## SSR head injection
|
|
204
|
+
|
|
205
|
+
`renderApp()` returns a `head` fragment. The framework injects it at the `<!--head-->` placeholder along with:
|
|
206
|
+
|
|
207
|
+
- `<script id="__finesoft_data__">` with serialized data (SSR mode only)
|
|
208
|
+
- `<link>` / `<script>` for client entry (production builds)
|
|
209
|
+
- `<html lang="..." dir="...">` attributes from the resolved locale
|
|
210
|
+
|
|
211
|
+
Custom meta tags go in your `head` string:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
async renderApp(page) {
|
|
215
|
+
return {
|
|
216
|
+
html: await renderToString(/*...*/),
|
|
217
|
+
head: [
|
|
218
|
+
`<title>${escape(page.title)}</title>`,
|
|
219
|
+
`<meta name="description" content="${escape(page.description)}">`,
|
|
220
|
+
`<meta property="og:title" content="${escape(page.title)}">`,
|
|
221
|
+
].join(""),
|
|
222
|
+
css: "",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Always escape user-provided strings — they go straight into HTML.
|
|
228
|
+
|
|
229
|
+
## CSS injection
|
|
230
|
+
|
|
231
|
+
If your render produces critical CSS (e.g., Vue scoped styles or `vanilla-extract`), return it as `css`:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
return {
|
|
235
|
+
html,
|
|
236
|
+
head: `<title>${title}</title>`,
|
|
237
|
+
css: extractedCriticalCss, // injected as <style> in <head>
|
|
238
|
+
};
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
For Vite-managed stylesheets, leave `css: ""` — the Vite plugin handles them.
|
|
242
|
+
|
|
243
|
+
## Status codes
|
|
244
|
+
|
|
245
|
+
The HTTP status of the SSR response follows this priority:
|
|
246
|
+
|
|
247
|
+
1. Middleware result: `deny(404)` → 404; `redirect(url, 301)` → 301 with `Location` header.
|
|
248
|
+
2. Page-level: a `Page` of `kind: "error"` returned by `fallback()` results in 500 (configurable via `getErrorPage`).
|
|
249
|
+
3. Default: 200.
|
|
250
|
+
|
|
251
|
+
Override via `afterLoad`:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
afterLoad: [
|
|
255
|
+
(ctx) => {
|
|
256
|
+
if (ctx.page.kind === "not-found") return deny(404, "Not found");
|
|
257
|
+
return next();
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Streaming SSR
|
|
263
|
+
|
|
264
|
+
Currently not supported. The framework awaits `renderApp()` fully before sending bytes. For most apps this is fine — `IntentDispatcher` parallelizes data fetching inside `execute()` if your controller awaits multiple HTTP calls together.
|
|
265
|
+
|
|
266
|
+
If you need streaming for a specific large page, consider rendering it CSR and using your view layer's own streaming primitives.
|
|
267
|
+
|
|
268
|
+
## Next
|
|
269
|
+
|
|
270
|
+
- [i18n](./05-i18n.md) — locale resolution and dictionary loading
|
|
271
|
+
- [Pitfalls: SSR hydration mismatch](./pitfalls/ssr-hydration-mismatch.md) — when the two sides disagree
|
package/docs/05-i18n.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# 5. Internationalization
|
|
2
|
+
|
|
3
|
+
The framework handles four i18n concerns:
|
|
4
|
+
|
|
5
|
+
1. **Resolving the user's locale** (cookie, accept-language, manual override)
|
|
6
|
+
2. **Loading the right dictionary** without bloating the bundle
|
|
7
|
+
3. **Translating strings** with interpolation and pluralization
|
|
8
|
+
4. **Rendering the correct text direction** (LTR / RTL)
|
|
9
|
+
|
|
10
|
+
## Locale resolution
|
|
11
|
+
|
|
12
|
+
Pass a default `locale` to `Framework.create()`:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const framework = Framework.create({ locale: "zh-Hans" });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For SSR, locale priority (highest wins):
|
|
19
|
+
|
|
20
|
+
1. `resolveLocale` callback in `createSSRRender({ resolveLocale })` — has access to request headers / cookies
|
|
21
|
+
2. `locale` in `frameworkConfig` (via DI container)
|
|
22
|
+
|
|
23
|
+
For the browser, the locale is whatever the server resolved (sent via the `<html lang>` attribute). `startBrowserApp` reads it and writes it back into `documentElement.lang`/`dir` on hydration.
|
|
24
|
+
|
|
25
|
+
### Reading at runtime
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const { lang, dir } = framework.getLocale();
|
|
29
|
+
// { lang: "zh-Hans", dir: "ltr" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Custom resolver
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { parseAcceptLanguage } from "@finesoft/front";
|
|
36
|
+
|
|
37
|
+
createSSRRender({
|
|
38
|
+
bootstrap,
|
|
39
|
+
resolveLocale(ctx) {
|
|
40
|
+
// 1. cookie
|
|
41
|
+
const fromCookie = ctx.getCookie("locale");
|
|
42
|
+
if (fromCookie && isSupported(fromCookie)) return fromCookie;
|
|
43
|
+
|
|
44
|
+
// 2. accept-language
|
|
45
|
+
const accept = ctx.getHeader("accept-language");
|
|
46
|
+
const best = parseAcceptLanguage(accept ?? "").find((l) => isSupported(l.tag));
|
|
47
|
+
if (best) return best.tag;
|
|
48
|
+
|
|
49
|
+
// 3. fallback
|
|
50
|
+
return "en-US";
|
|
51
|
+
},
|
|
52
|
+
async renderApp(page) {
|
|
53
|
+
/* ... */
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`parseAcceptLanguage` parses `Accept-Language: en;q=0.9,fr;q=0.8` into ranked tags. Tags with `q=0` are filtered out.
|
|
59
|
+
|
|
60
|
+
## Dictionary loading via `messagesDir`
|
|
61
|
+
|
|
62
|
+
The recommended pattern is JSON files + the Vite plugin:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// vite.config.ts
|
|
66
|
+
finesoftFrontViteConfig({
|
|
67
|
+
ssr: { entry: "src/ssr.ts" },
|
|
68
|
+
i18n: { messagesDir: "src/locales" },
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
src/locales/
|
|
74
|
+
├── en-US.json
|
|
75
|
+
├── zh-Hans.json
|
|
76
|
+
└── ja-JP.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
// src/locales/zh-Hans.json
|
|
81
|
+
{
|
|
82
|
+
"hello": "你好",
|
|
83
|
+
"welcome": "欢迎,{name}",
|
|
84
|
+
"items.one": "{count} 个项目",
|
|
85
|
+
"items.other": "{count} 个项目"
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The plugin generates code that:
|
|
90
|
+
|
|
91
|
+
- Loads only the resolved locale's JSON (server side: read from disk; browser side: dynamic import chunk)
|
|
92
|
+
- Caches across requests on the server
|
|
93
|
+
- Is keyed by the resolved locale, so changing locale triggers a re-fetch
|
|
94
|
+
|
|
95
|
+
You **do not** serialize the dictionary into the HTML payload. The browser fetches its locale chunk in parallel with the initial render.
|
|
96
|
+
|
|
97
|
+
## `SimpleTranslator`
|
|
98
|
+
|
|
99
|
+
For in-memory dictionaries (small apps, tests, or manually loaded data):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { SimpleTranslator } from "@finesoft/front";
|
|
103
|
+
|
|
104
|
+
const t = new SimpleTranslator({
|
|
105
|
+
locale: "zh-Hans",
|
|
106
|
+
messages: {
|
|
107
|
+
hello: "你好",
|
|
108
|
+
welcome: "欢迎,{name}",
|
|
109
|
+
"items.one": "{count} 个项目",
|
|
110
|
+
"items.other": "{count} 个项目",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
t.t("hello"); // "你好"
|
|
115
|
+
t.t("welcome", { name: "World" }); // "欢迎,World"
|
|
116
|
+
t.plural("items", 5); // "5 个项目"
|
|
117
|
+
t.plural("items", 1); // "1 个项目"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Interpolation
|
|
121
|
+
|
|
122
|
+
Curly-brace placeholders: `{name}`, `{count}`, `{0}`. Values are HTML-escaped only if you pass them through your view layer's escape — `SimpleTranslator` returns raw strings.
|
|
123
|
+
|
|
124
|
+
### Pluralization
|
|
125
|
+
|
|
126
|
+
Backed by `Intl.PluralRules`. Keys use CLDR plural categories: `zero`, `one`, `two`, `few`, `many`, `other`. Always provide `other` as the fallback.
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"messages.zero": "No messages",
|
|
131
|
+
"messages.one": "1 message",
|
|
132
|
+
"messages.other": "{count} messages"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
t.plural("messages", 0); // "No messages"
|
|
138
|
+
t.plural("messages", 1); // "1 message"
|
|
139
|
+
t.plural("messages", 5); // "5 messages"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Languages with richer plural systems (Russian, Arabic) automatically pick `few`/`many` if present.
|
|
143
|
+
|
|
144
|
+
## Integrating with DI
|
|
145
|
+
|
|
146
|
+
Register the translator in your container:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
container.register(
|
|
150
|
+
"translator",
|
|
151
|
+
() =>
|
|
152
|
+
new SimpleTranslator({
|
|
153
|
+
locale: framework.getLocale().lang,
|
|
154
|
+
messages: loadedMessages,
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Or use the framework's built-in DI key:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { DEP_KEYS } from "@finesoft/front";
|
|
163
|
+
|
|
164
|
+
container.register(DEP_KEYS.TRANSLATOR, () => translator);
|
|
165
|
+
|
|
166
|
+
// Later, in any component or controller:
|
|
167
|
+
const t = framework.container.resolve(DEP_KEYS.TRANSLATOR);
|
|
168
|
+
t.t("hello");
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Custom message source
|
|
172
|
+
|
|
173
|
+
If you need to load messages from an API or CDN, override `loadMessages` on `createSSRRender` / `startBrowserApp`:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
createSSRRender({
|
|
177
|
+
bootstrap,
|
|
178
|
+
async loadMessages(locale) {
|
|
179
|
+
const resp = await fetch(`https://cdn.example.com/i18n/${locale}.json`);
|
|
180
|
+
return resp.json();
|
|
181
|
+
},
|
|
182
|
+
async renderApp(page) {
|
|
183
|
+
/* ... */
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
This **overrides** the Vite-generated loader. Use it when:
|
|
189
|
+
|
|
190
|
+
- Translations are managed by a service (Lokalise, Phrase) and fetched at runtime
|
|
191
|
+
- You want stale-while-revalidate caching
|
|
192
|
+
- You need to merge multiple namespaces from different sources
|
|
193
|
+
|
|
194
|
+
For most apps, the file-based loader is enough — it ships exactly one locale's bytes, no runtime fetch needed.
|
|
195
|
+
|
|
196
|
+
## RTL support
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { isRtl, getTextDirection, getLocaleAttributes } from "@finesoft/front";
|
|
200
|
+
|
|
201
|
+
isRtl("ar"); // true
|
|
202
|
+
isRtl("he"); // true
|
|
203
|
+
isRtl("zh-Hans"); // false
|
|
204
|
+
|
|
205
|
+
getTextDirection("ar"); // "rtl"
|
|
206
|
+
getTextDirection("en"); // "ltr"
|
|
207
|
+
|
|
208
|
+
getLocaleAttributes("ar-SA"); // { lang: "ar-SA", dir: "rtl" }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The framework sets `<html dir="rtl">` automatically for RTL locales. Your CSS should use logical properties (`margin-inline-start` instead of `margin-left`) for layout that mirrors correctly.
|
|
212
|
+
|
|
213
|
+
```css
|
|
214
|
+
/* good */
|
|
215
|
+
.card {
|
|
216
|
+
padding-inline-start: 16px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* avoid — won't mirror in RTL */
|
|
220
|
+
.card {
|
|
221
|
+
padding-left: 16px;
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Locale switching
|
|
226
|
+
|
|
227
|
+
For a user-initiated locale switch:
|
|
228
|
+
|
|
229
|
+
1. Update the cookie / user preference on the server: `Set-Cookie: locale=ja-JP`.
|
|
230
|
+
2. Trigger a full reload (`window.location.reload()`) so the server resolves the new locale, loads the new dictionary, and re-renders.
|
|
231
|
+
|
|
232
|
+
A purely client-side switch is possible but skips SSR re-render — first-paint will show the old locale until the new dictionary loads. For most apps the full reload is simpler and correct.
|
|
233
|
+
|
|
234
|
+
## Caveats
|
|
235
|
+
|
|
236
|
+
- **Don't serialize the entire dictionary into HTML.** It bloats first paint. The file-based loader sends only the current locale, and only its chunk. See [pitfalls: i18n bundle size](./pitfalls/i18n-bundle-size.md).
|
|
237
|
+
- **Don't mutate the dictionary at runtime.** The cache assumes immutability. If you need dynamic strings (user-generated content), keep them separate from translations.
|
|
238
|
+
- **`SimpleTranslator` is synchronous.** If your translation source is async, load it before the controller runs (e.g., in `beforeLoad` or `onBeforeStart`).
|
|
239
|
+
|
|
240
|
+
## Next
|
|
241
|
+
|
|
242
|
+
- [HTTP client](./06-http-client.md) — making requests, with locale headers when needed
|
|
243
|
+
- [Pitfalls: i18n bundle size](./pitfalls/i18n-bundle-size.md) — keeping translations off the critical path
|