@nmvuong92/fluxe 0.1.1 → 0.2.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/lib/core/engine.d.ts +3 -0
- package/lib/core/i18n.d.ts +17 -0
- package/lib/core/i18n.js +40 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/react/LocaleSwitch.d.ts +6 -0
- package/lib/react/LocaleSwitch.js +8 -0
- package/lib/react/index.d.ts +1 -0
- package/lib/react/index.js +1 -0
- package/lib/react/shell.d.ts +1 -1
- package/lib/react/shell.js +1 -1
- package/lib/server_factory.d.ts +4 -1
- package/lib/server_factory.js +25 -8
- package/package.json +1 -1
package/lib/core/engine.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ import type { ComponentType } from "react";
|
|
|
2
2
|
import type { Backend } from "../backends/types";
|
|
3
3
|
import type { HeadMeta } from "./seo";
|
|
4
4
|
import type { Session } from "./auth";
|
|
5
|
+
import type { TFn } from "./i18n";
|
|
5
6
|
export interface Ctx<I> {
|
|
6
7
|
input: I;
|
|
7
8
|
backend: Backend;
|
|
8
9
|
session?: Session | null;
|
|
10
|
+
locale?: string;
|
|
11
|
+
t?: TFn;
|
|
9
12
|
}
|
|
10
13
|
export type Loader<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
11
14
|
export type Action<I, O> = (ctx: Ctx<I>) => Promise<O>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Messages = Record<string, string>;
|
|
2
|
+
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
|
3
|
+
export interface I18n {
|
|
4
|
+
locales: string[];
|
|
5
|
+
defaultLocale: string;
|
|
6
|
+
catalogs: Record<string, Messages>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createI18n(opts: {
|
|
9
|
+
defaultLocale: string;
|
|
10
|
+
catalogs: Record<string, Messages>;
|
|
11
|
+
}): I18n;
|
|
12
|
+
export declare function translate(i18n: I18n, locale: string, key: string, vars?: Record<string, string | number>): string;
|
|
13
|
+
export declare function makeT(i18n: I18n, locale: string): TFn;
|
|
14
|
+
export declare function resolveLocale(i18n: I18n, opts: {
|
|
15
|
+
cookie?: string;
|
|
16
|
+
acceptLanguage?: string;
|
|
17
|
+
}): string;
|
package/lib/core/i18n.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) 2026 nmvuong92
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/* i18n — THUẦN, testable. Catalog message theo locale + t(key, vars) interpolate {var}.
|
|
4
|
+
* resolveLocale: chọn locale từ cookie / Accept-Language, giới hạn trong locale có thật. */
|
|
5
|
+
export function createI18n(opts) {
|
|
6
|
+
const locales = Object.keys(opts.catalogs);
|
|
7
|
+
if (!locales.includes(opts.defaultLocale)) {
|
|
8
|
+
throw new Error(`i18n: defaultLocale '${opts.defaultLocale}' không có trong catalogs`);
|
|
9
|
+
}
|
|
10
|
+
return { locales, defaultLocale: opts.defaultLocale, catalogs: opts.catalogs };
|
|
11
|
+
}
|
|
12
|
+
/* Tra key trong catalog locale → fallback defaultLocale → fallback chính key. Interpolate {var}. */
|
|
13
|
+
export function translate(i18n, locale, key, vars) {
|
|
14
|
+
const msg = i18n.catalogs[locale]?.[key] ?? i18n.catalogs[i18n.defaultLocale]?.[key] ?? key;
|
|
15
|
+
if (!vars)
|
|
16
|
+
return msg;
|
|
17
|
+
return msg.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? String(vars[k]) : `{${k}}`));
|
|
18
|
+
}
|
|
19
|
+
/* t() đã bind locale — dùng trong loader/view. */
|
|
20
|
+
export function makeT(i18n, locale) {
|
|
21
|
+
return (key, vars) => translate(i18n, locale, key, vars);
|
|
22
|
+
}
|
|
23
|
+
/* Chọn locale: cookie hợp lệ > Accept-Language (khớp tag hoặc base) > defaultLocale. */
|
|
24
|
+
export function resolveLocale(i18n, opts) {
|
|
25
|
+
if (opts.cookie && i18n.locales.includes(opts.cookie))
|
|
26
|
+
return opts.cookie;
|
|
27
|
+
if (opts.acceptLanguage) {
|
|
28
|
+
for (const part of opts.acceptLanguage.split(",")) {
|
|
29
|
+
const tag = part.split(";")[0].trim(); // "en-US"
|
|
30
|
+
if (!tag)
|
|
31
|
+
continue;
|
|
32
|
+
if (i18n.locales.includes(tag))
|
|
33
|
+
return tag;
|
|
34
|
+
const base = tag.split("-")[0]; // "en"
|
|
35
|
+
if (i18n.locales.includes(base))
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return i18n.defaultLocale;
|
|
40
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from "./core/resolver.ts";
|
|
|
5
5
|
export * from "./core/wiring.ts";
|
|
6
6
|
export * from "./core/auth.ts";
|
|
7
7
|
export * from "./core/env.ts";
|
|
8
|
+
export * from "./core/i18n.ts";
|
|
8
9
|
export * from "./core/seo.ts";
|
|
9
10
|
export * from "./core/broker.ts";
|
|
10
11
|
export * from "./core/presence.ts";
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./core/resolver.js"; // resolve, ResolutionProfile/Manifest, Cell
|
|
|
9
9
|
export * from "./core/wiring.js"; // backendFromManifest, backendsFromManifest
|
|
10
10
|
export * from "./core/auth.js"; // session HMAC, scrypt password, CSRF, RBAC
|
|
11
11
|
export * from "./core/env.js"; // loadEnv
|
|
12
|
+
export * from "./core/i18n.js"; // createI18n, resolveLocale, translate, makeT, t(key, vars)
|
|
12
13
|
export * from "./core/seo.js"; // renderHead, renderSitemap, renderRobots, HeadMeta
|
|
13
14
|
export * from "./core/broker.js"; // pub/sub
|
|
14
15
|
export * from "./core/presence.js"; // ai online theo topic
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2026 nmvuong92
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
/* <LocaleSwitch> — đổi ngôn ngữ qua link ?locale=xx (server set cookie + redirect).
|
|
5
|
+
* Render HTML thuần → chạy cả trên cell static (0 JS). Active theo `current` (ctx.locale). */
|
|
6
|
+
export function LocaleSwitch({ locales, current, labels, className, }) {
|
|
7
|
+
return (_jsx("span", { className: className ?? "locale-switch", children: locales.map((l) => (_jsx("a", { href: `?locale=${l}`, className: l === current ? "locale active" : "locale", "aria-current": l === current ? "true" : undefined, children: labels?.[l] ?? l.toUpperCase() }, l))) }));
|
|
8
|
+
}
|
package/lib/react/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { Link } from "./Link";
|
|
|
4
4
|
export { Nav, type NavItem } from "./Nav";
|
|
5
5
|
export { useTheme, type Theme } from "./theme";
|
|
6
6
|
export { ThemeToggle } from "./ThemeToggle";
|
|
7
|
+
export { LocaleSwitch } from "./LocaleSwitch";
|
|
7
8
|
export { shellScript } from "./shell";
|
|
8
9
|
export { DebugBar } from "./DebugBar";
|
|
9
10
|
export { debug, DebugStore } from "./store";
|
package/lib/react/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { Link } from "./Link";
|
|
|
8
8
|
export { Nav } from "./Nav";
|
|
9
9
|
export { useTheme } from "./theme";
|
|
10
10
|
export { ThemeToggle } from "./ThemeToggle";
|
|
11
|
+
export { LocaleSwitch } from "./LocaleSwitch";
|
|
11
12
|
export { shellScript } from "./shell";
|
|
12
13
|
export { DebugBar } from "./DebugBar";
|
|
13
14
|
export { debug, DebugStore } from "./store";
|
package/lib/react/shell.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const shellScript = "(function(){\nvar d=document.documentElement;\ntry{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}\nfunction active(){var p=location.pathname;document.querySelectorAll('.nav-link').forEach(function(a){try{a.classList.toggle('active',new URL(a.href).pathname===p);}catch(e){}});}\nfunction wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}});}active();}\nif(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);\naddEventListener('fluxe:nav',active);addEventListener('popstate',active);\n})();";
|
|
1
|
+
export declare const shellScript = "(function(){\nvar d=document.documentElement;\ntry{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}\nfunction active(){var p=location.pathname;document.querySelectorAll('.nav-link').forEach(function(a){try{a.classList.toggle('active',new URL(a.href).pathname===p);}catch(e){}});}\nfunction wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}document.cookie='theme='+n+';Path=/;Max-Age=31536000;SameSite=Lax';});}active();}\nif(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);\naddEventListener('fluxe:nav',active);addEventListener('popstate',active);\n})();";
|
package/lib/react/shell.js
CHANGED
|
@@ -9,7 +9,7 @@ export const shellScript = `(function(){
|
|
|
9
9
|
var d=document.documentElement;
|
|
10
10
|
try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')d.dataset.theme=t;}catch(e){}
|
|
11
11
|
function active(){var p=location.pathname;document.querySelectorAll('.nav-link').forEach(function(a){try{a.classList.toggle('active',new URL(a.href).pathname===p);}catch(e){}});}
|
|
12
|
-
function wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}});}active();}
|
|
12
|
+
function wire(){var b=document.querySelector('[data-fluxe-theme-toggle]');if(b&&!b._w){b._w=1;b.addEventListener('click',function(){var n=d.dataset.theme==='dark'?'light':'dark';d.dataset.theme=n;try{localStorage.setItem('theme',n);}catch(e){}document.cookie='theme='+n+';Path=/;Max-Age=31536000;SameSite=Lax';});}active();}
|
|
13
13
|
if(document.readyState!=='loading')wire();else document.addEventListener('DOMContentLoaded',wire);
|
|
14
14
|
addEventListener('fluxe:nav',active);addEventListener('popstate',active);
|
|
15
15
|
})();`;
|
package/lib/server_factory.d.ts
CHANGED
|
@@ -8,5 +8,8 @@ type LayoutEntry = LayoutMeta & {
|
|
|
8
8
|
}) => any;
|
|
9
9
|
};
|
|
10
10
|
type LayoutMap = Record<string, LayoutEntry>;
|
|
11
|
-
|
|
11
|
+
import { type I18n } from "./core/i18n.ts";
|
|
12
|
+
export declare function makeServer(manifest: ResolutionManifest, cells: CellDef<any, any>[], layouts?: LayoutMap, opts?: {
|
|
13
|
+
i18n?: I18n;
|
|
14
|
+
}): http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
|
12
15
|
export {};
|
package/lib/server_factory.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createPresence } from "./core/presence.js";
|
|
|
20
20
|
import { etagOf, etagMatches } from "./core/etag.js";
|
|
21
21
|
import { createRenderCache } from "./core/rendercache.js";
|
|
22
22
|
import { parseChaos } from "./core/chaos.js";
|
|
23
|
+
import { resolveLocale, makeT } from "./core/i18n.js";
|
|
23
24
|
import { createMemoryBackend } from "./backends/memory.js";
|
|
24
25
|
import { createHttpBackend } from "./backends/http.js";
|
|
25
26
|
// Build backend theo ngôn ngữ (cho live swap trong devtools).
|
|
@@ -59,9 +60,10 @@ function sendError(res, wantsJson, err) {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
// Phần head (trước body) và tail (sau body) — body được STREAM ở giữa.
|
|
62
|
-
function shellHead(cell, data) {
|
|
63
|
+
function shellHead(cell, data, lang = "vi", theme = "") {
|
|
63
64
|
const headHtml = renderHead(cell.head ? cell.head(data) : {});
|
|
64
|
-
|
|
65
|
+
const themeAttr = theme ? ` data-theme="${theme}"` : ""; // theme-SSR: no-flash ngay lần đầu
|
|
66
|
+
return `<!doctype html><html lang="${lang}"${themeAttr}><head><meta charset="utf-8">${headHtml}</head><body><div id="root">`;
|
|
65
67
|
}
|
|
66
68
|
function shellTail(cell, data, shipClientJs) {
|
|
67
69
|
const island = shipClientJs
|
|
@@ -85,7 +87,8 @@ function renderBodyToString(node) {
|
|
|
85
87
|
});
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
|
-
export function makeServer(manifest, cells, layouts = {}) {
|
|
90
|
+
export function makeServer(manifest, cells, layouts = {}, opts = {}) {
|
|
91
|
+
const i18n = opts.i18n;
|
|
89
92
|
// Cells được TIÊM từ app (DI) — engine không import ngược vào app/. Thêm trang = sửa app/app.ts.
|
|
90
93
|
const matchRoute = makeRouter(cells);
|
|
91
94
|
const byId = new Map(cells.map((c) => [c.id, c]));
|
|
@@ -108,7 +111,20 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
108
111
|
// CSRF double-submit: đảm bảo có cookie csrf (đặt nếu chưa) — client gửi lại qua header.
|
|
109
112
|
let csrf = cookies.csrf;
|
|
110
113
|
const csrfCookie = csrf ? "" : (csrf = newCsrfToken(), `csrf=${csrf}; Path=/; SameSite=Lax`);
|
|
114
|
+
// Resolved Shell: locale (i18n) + theme — giải từ cookie/header, đưa vào loader + <html>.
|
|
115
|
+
const locale = i18n ? resolveLocale(i18n, { cookie: cookies.locale, acceptLanguage: req.headers["accept-language"] }) : "vi";
|
|
116
|
+
const t = i18n ? makeT(i18n, locale) : (k) => k;
|
|
117
|
+
const theme = cookies.theme === "dark" || cookies.theme === "light" ? cookies.theme : "";
|
|
111
118
|
try {
|
|
119
|
+
// Đổi ngôn ngữ qua ?locale=xx → set cookie + redirect (chạy cả trên cell static, 0 JS).
|
|
120
|
+
{
|
|
121
|
+
const ql = url.searchParams.get("locale");
|
|
122
|
+
if (i18n && ql && i18n.locales.includes(ql)) {
|
|
123
|
+
url.searchParams.delete("locale");
|
|
124
|
+
res.writeHead(303, { "set-cookie": `locale=${ql}; Path=/; SameSite=Lax`, location: url.pathname + (url.search || "") });
|
|
125
|
+
return res.end();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
112
128
|
if (url.pathname === "/client.js") {
|
|
113
129
|
if (clientJs === undefined && existsSync("./dist/client.js"))
|
|
114
130
|
clientJs = readFileSync("./dist/client.js");
|
|
@@ -243,7 +259,7 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
243
259
|
if (cell.requireRole && !hasRole(session, cell.requireRole)) {
|
|
244
260
|
throw new FluxeError("forbidden", `Cần quyền '${cell.requireRole}'`, 403);
|
|
245
261
|
}
|
|
246
|
-
const data = await cell.loader({ input: match.params, backend: backendFor(cell.id), session });
|
|
262
|
+
const data = await cell.loader({ input: match.params, backend: backendFor(cell.id), session, locale, t });
|
|
247
263
|
if (wantsJson) {
|
|
248
264
|
const body = JSON.stringify({ cell: cell.id, data, layout: cell.layout });
|
|
249
265
|
const etag = etagOf(body); // render cache: 304 nếu props không đổi
|
|
@@ -255,8 +271,9 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
255
271
|
return res.end(body);
|
|
256
272
|
}
|
|
257
273
|
let node = h(cell.view, { data });
|
|
274
|
+
const shellCtx = { locale, t, theme, path: url.pathname }; // Resolved Shell ctx cho layout
|
|
258
275
|
for (const id of layoutChain(cell.layout, layouts)) { // inner→outer: bọc dần
|
|
259
|
-
node = h(layouts[id].component, { children: node });
|
|
276
|
+
node = h(layouts[id].component, { children: node, ctx: shellCtx });
|
|
260
277
|
}
|
|
261
278
|
const shipClientJs = manifest.cells[cell.id]?.render.shipClientJs ?? false;
|
|
262
279
|
const pageHeaders = { "content-type": "text/html; charset=utf-8" };
|
|
@@ -265,10 +282,10 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
265
282
|
// Ý B — render cache cell static: render 1 lần → giữ Buffer → ghi lại (zero-copy).
|
|
266
283
|
// Gate etag(data): data đổi ⇒ miss ⇒ render lại (không trả HTML cũ). Chỉ cell static & public.
|
|
267
284
|
if (manifest.cells[cell.id]?.render.mode === "static" && cell.cache !== false && !cell.requireAuth && !cell.requireRole) {
|
|
268
|
-
const etag = etagOf(JSON.stringify(data));
|
|
285
|
+
const etag = etagOf(JSON.stringify(data) + "|" + locale + "|" + theme); // lang/theme đổi → bust cache
|
|
269
286
|
let hit = renderCache.get(url.pathname);
|
|
270
287
|
if (!hit || hit.etag !== etag) {
|
|
271
|
-
const full = shellHead(cell, data) + await renderBodyToString(node) + shellTail(cell, data, shipClientJs);
|
|
288
|
+
const full = shellHead(cell, data, locale, theme) + await renderBodyToString(node) + shellTail(cell, data, shipClientJs);
|
|
272
289
|
hit = { etag, buf: Buffer.from(full, "utf8") };
|
|
273
290
|
renderCache.set(url.pathname, hit);
|
|
274
291
|
}
|
|
@@ -277,7 +294,7 @@ export function makeServer(manifest, cells, layouts = {}) {
|
|
|
277
294
|
}
|
|
278
295
|
// Streaming SSR: gửi head ngay → stream body (Suspense chảy dần) → tail khi xong.
|
|
279
296
|
res.writeHead(200, pageHeaders);
|
|
280
|
-
res.write(shellHead(cell, data));
|
|
297
|
+
res.write(shellHead(cell, data, locale, theme));
|
|
281
298
|
const through = new PassThrough();
|
|
282
299
|
through.on("data", (c) => res.write(c));
|
|
283
300
|
through.on("end", () => { res.write(shellTail(cell, data, shipClientJs)); res.end(); });
|