diff --git a/deno.json b/deno.json index 47f1857..3a82168 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "importMap": "./import_map.json", "tasks": { - "server-dev": "deno run -A --unstable --watch=static/,routes/ server-dev.ts", + "server-dev": "deno run -A --unstable --watch=static/,routes/,translation/ server-dev.ts", "test": "deno test --allow-read=./ --allow-net --allow-write=./ --allow-run=tasklist.exe --unstable", "run": "deno run --allow-read=./ --allow-write=./ --allow-run=tasklist.exe --allow-env=DENO_DEPLOYMENT_ID --allow-net --unstable", "compile": "deno compile --allow-read=./ --allow-write=./ --allow-run=tasklist.exe --allow-env=DENO_DEPLOYMENT_ID --allow-net --unstable", diff --git a/deno.lock b/deno.lock index 3709c9e..29ab44b 100644 --- a/deno.lock +++ b/deno.lock @@ -193,7 +193,10 @@ "https://deno.land/x/zipjs@v2.7.14/lib/core/zip-writer.js": "34809b421f5deb497ce8cabec730af858c12dde54a6205c78b5591460785dc1e", "https://deno.land/x/zipjs@v2.7.14/lib/z-worker-inline.js": "df83d91413a2e79f76924f39f26f59e6efbe8f5487d3a91b7e92b6d64236927c", "https://deno.land/x/zipjs@v2.7.14/lib/zip-fs.js": "a733360302f5fbec9cc01543cb9fcfe7bae3f35a50d0006626ce42fe8183b63f", + "https://esm.sh/*@preact/signals-core@1.2.3": "40fc366f6816cf9eb5a0e25aa79fd531b851a70f678f8d6b348feb43624c09e5", + "https://esm.sh/*@preact/signals@1.1.3": "f1591d7185a00b6f96fdf5f72a99bb7dde37c0e946c8854da71db6b99d430947", "https://esm.sh/*preact-render-to-string@5.2.6": "88ec8d8706b6a3f1e0fdad3862a2690dcd9b350d87bdc8e7bd0e27fbc0f7d29e", + "https://esm.sh/accept-language-parser@1.5.0/": "16d82ee0a75451f75b42d9a20db4da0ccae7ccc8cc09a41c73b4488aba010b94", "https://esm.sh/preact-material-components@1.6.1/Button": "bc60923d511c6e2e33a7064339b3e643a9c15e3ef232ab063ef570af2ef83dc8", "https://esm.sh/preact-material-components@1.6.1/Checkbox": "bf34f5cd8c6d015916d854d91aab2caf115463e97be9a461f8dd3370ea11a49c", "https://esm.sh/preact-material-components@1.6.1/Dialog": "b0ff8da9c770456748f7e065fecda2fc90f5364ea66cae75ff5f51d57f6a87eb", @@ -294,6 +297,12 @@ "https://esm.sh/v124/@material/ripple@0.39.3/deno/ripple.mjs": "df299ee5ebdca82a60414bd942f39bd08eddb47071b7e8e1bcb2ae87f73f2c94", "https://esm.sh/v124/@material/ripple@0.39.3/deno/util.js": "4df787664fc517d4545bb7d502f85de3b34cfa613e34edd6b4630ea21e673cf2", "https://esm.sh/v124/@material/selection-control@0.39.3/deno/index.js": "de08ffa76efb31c7fe817ea4a6da471cfe19060f49a21c6cbe6aa9533dc95aa1", + "https://esm.sh/v124/@preact/signals-core@1.2.3/X-ZS8q/deno/signals-core.mjs": "c2787059fe616fcc0504a99a77930723c582d2630cae69db695a8765d8ed426e", + "https://esm.sh/v124/@preact/signals-core@1.2.3/X-ZS8q/dist/signals-core.d.ts": "e39c59670049b772b2c7748907af01f4fb5d5706b88fa8266d9135925ca5baf7", + "https://esm.sh/v124/@preact/signals@1.1.3/X-ZS8q/deno/signals.mjs": "9107036efc63362c0d88965534957e5c9e3cc4c6da6f10f7a7dcb3e8a29fb624", + "https://esm.sh/v124/@preact/signals@1.1.3/X-ZS8q/dist/signals.d.ts": "2c766d8d5911bcbb1aac4ac1da8965e830c54bcb96da8feb4f4b3e5dcd9ee02c", + "https://esm.sh/v124/@types/accept-language-parser@1.5.3/index.d.ts": "cc67c7ba07d35570b854f24fd490a6a42e5caa6e17cb12dd360f25ba65b78754", + "https://esm.sh/v124/accept-language-parser@1.5.0/deno/accept-language-parser.mjs": "ed949ec1b8f1d41f9b98cee8e8f9af8ce1300e5dae29a61c16b66ab16c7e35ec", "https://esm.sh/v124/bind-decorator@1.0.11/deno/bind-decorator.mjs": "21a126bdebaca6e38120139d903c3485c4f57e15487646666e79c1b5c15d0e44", "https://esm.sh/v124/csstype@3.1.2/index.d.ts": "4c68749a564a6facdf675416d75789ee5a557afda8960e0803cf6711fa569288", "https://esm.sh/v124/preact-material-components@1.6.1/Base/MaterialComponent.d.ts": "2ea7a1ffbff6c43619f3f85d69a9e9a3c62575f409fc93ad651f6906adba47d0", diff --git a/import_map.json b/import_map.json index 64e3199..c4a6644 100644 --- a/import_map.json +++ b/import_map.json @@ -13,6 +13,7 @@ "twind": "https://esm.sh/twind@0.16.19", "twind/": "https://esm.sh/twind@0.16.19/", "$std/": "https://deno.land/std@0.187.0/", - "preact-material-components/": "https://esm.sh/preact-material-components@1.6.1/" + "preact-material-components/": "https://esm.sh/preact-material-components@1.6.1/", + "accept-language-parser/": "https://esm.sh/accept-language-parser@1.5.0/" } } diff --git a/islands/Container.tsx b/islands/Container.tsx index 4fc493a..2f6768d 100644 --- a/islands/Container.tsx +++ b/islands/Container.tsx @@ -7,11 +7,17 @@ import TopAppBar from "preact-material-components/TopAppBar"; import StyleSheet from "../components/StyleSheet.tsx"; import { GlobalCtx } from "../components/GlobalContext.tsx"; import Settings from "./Settings.tsx"; +import t, { i18n_map, I18NMap } from "../server/i18n.ts"; -export default class Container extends Component { +export type ContainerProps = { + i18n: I18NMap; +}; + +export default class Container extends Component { static contextType = GlobalCtx; declare context: ContextType; render() { + i18n_map.value = this.props.i18n; const [display, set_display] = useState(false); const [show_settings, set_show_settings] = useState(false); const close_all = () => { @@ -21,6 +27,7 @@ export default class Container extends Component { return (
+ {t("common.title")} @@ -32,7 +39,7 @@ export default class Container extends Component { menu - EH Downloader + {t("common.title")} diff --git a/islands/Settings.tsx b/islands/Settings.tsx index fc5f5c9..ae37d67 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -9,6 +9,7 @@ import { ConfigType } from "../config.ts"; import SettingsCheckbox from "../components/SettingsCheckbox.tsx"; import SettingsContext from "../components/SettingsContext.tsx"; import SettingsText from "../components/SettingsText.tsx"; +import { i18n_map } from "../server/i18n.ts"; export type SettingsProps = { show: boolean; diff --git a/routes/index.tsx b/routes/index.tsx index b88de1f..e7acf47 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,15 +1,23 @@ import { Head } from "$fresh/runtime.ts"; +import { Handlers, PageProps } from "$fresh/server.ts"; import GlobalContext from "../components/GlobalContext.tsx"; import Container from "../islands/Container.tsx"; +import { get_i18nmap, i18n_handle_request } from "../server/i18ns.ts"; -export default function Index() { +export const handler: Handlers = { + GET(req, ctx) { + const re = i18n_handle_request(req); + if (typeof re === "string") return ctx.render(re); + return re; + }, +}; + +export default function Index({ data }: PageProps) { + const i18n = get_i18nmap(data); return ( - - EH Downloader - - + ); diff --git a/server.ts b/server.ts index 1432da4..2521c14 100644 --- a/server.ts +++ b/server.ts @@ -1,9 +1,10 @@ -import { start } from "$fresh/server.ts"; +import { RenderFunction, start } from "$fresh/server.ts"; import { load_settings } from "./config.ts"; import manifest from "./fresh.gen.ts"; import { TaskManager } from "./task_manager.ts"; import twindPlugin from "$fresh/plugins/twind.ts"; import twindConfig from "./twind.config.ts"; +import { load_translation } from "./server/i18ns.ts"; let task_manager: TaskManager | undefined = undefined; let cfg_path: string | undefined = undefined; @@ -18,12 +19,21 @@ export function get_cfg_path() { return cfg_path; } +const renderFn: RenderFunction = (ctx, render) => { + const u = new URL(ctx.url); + const lang = u.searchParams.get("lang"); + if (lang) ctx.lang = lang; + render(); +}; + export async function startServer(path: string) { cfg_path = path; const cfg = await load_settings(path); task_manager = new TaskManager(cfg); + await load_translation(task_manager.aborts); return start(manifest, { signal: task_manager.aborts, plugins: [twindPlugin(twindConfig)], + render: renderFn, }); } diff --git a/server/i18n.ts b/server/i18n.ts new file mode 100644 index 0000000..bb0190a --- /dev/null +++ b/server/i18n.ts @@ -0,0 +1,16 @@ +import { signal } from "@preact/signals"; + +export type I18NMap = { [x: string]: string | I18NMap }; + +export const i18n_map = signal({}); +const NOT_FOUND = "__NOT_FOUND__"; + +export default function t(key: string) { + const keys = key.split("."); + let map: string | I18NMap = i18n_map.value; + for (const k of keys) { + if (typeof map === "string") return NOT_FOUND; + else map = map[k]; + } + return typeof map === "string" ? map : NOT_FOUND; +} diff --git a/server/i18ns.ts b/server/i18ns.ts new file mode 100644 index 0000000..dd8d8da --- /dev/null +++ b/server/i18ns.ts @@ -0,0 +1,72 @@ +import { exists } from "std/fs/exists.ts"; +import { parse } from "std/jsonc/mod.ts"; +import { join } from "std/path/mod.ts"; +import { I18NMap } from "./i18n.ts"; +import { pick } from "accept-language-parser/"; + +const whole_maps = new Map(); +const LANGUAGES = ["zh-cn"]; +type MODULE = "common"; +const MODULES: MODULE[] = ["common"]; + +export async function load_translation(signal?: AbortSignal) { + const base = import.meta.resolve("../translation").slice(8); + const enmap: I18NMap = {}; + for (const m of MODULES) { + const t = await Deno.readTextFile(join(base, "en", m + ".jsonc"), { + signal, + }); + const v = parse(t); + enmap[m] = v; + } + whole_maps.set("en", enmap); + for (const l of LANGUAGES) { + const map: I18NMap = {}; + for (const m of MODULES) { + const p = join(base, l, m + ".jsonc"); + if (await exists(p)) { + const t = await Deno.readTextFile(join(base, l, m + ".jsonc"), { + signal, + }); + const v = Object.assign( + structuredClone(enmap[m]), + parse(t), + ); + map[m] = v; + } else map[m] = enmap[m]; + } + whole_maps.set(l, map); + } +} + +export function get_i18nmap(lang: string, ...modules: MODULE[]) { + const m = whole_maps.get(lang); + if (!m) throw Error(`Language ${lang} is not supported.`); + if (!modules.length) return m; + const r: I18NMap = {}; + for (const n of modules) { + r[n] = m[n]; + } + return r; +} + +export function i18n_handle_request(req: Request) { + const u = new URL(req.url); + const lang = u.searchParams.get("lang"); + if (lang && (lang === "en" || LANGUAGES.includes(lang))) { + return lang; + } else { + const a = req.headers.get("Accept-Language"); + const l = (a + ? pick(LANGUAGES, a) || pick(LANGUAGES, a, { loose: true }) + : null) || "en"; + const params = new URLSearchParams(); + params.append("lang", l); + for (const p of u.searchParams.entries()) { + if (p[0] !== "lang") { + params.append(p[0], p[1]); + } + } + return Response.redirect(`${u.origin}${u.pathname}?${params}`); + } +} diff --git a/translation/en/common.jsonc b/translation/en/common.jsonc new file mode 100644 index 0000000..2f6bad2 --- /dev/null +++ b/translation/en/common.jsonc @@ -0,0 +1,3 @@ +{ + "title": "EH Downloader" +} diff --git a/translation/zh-cn/common.jsonc b/translation/zh-cn/common.jsonc new file mode 100644 index 0000000..1900e17 --- /dev/null +++ b/translation/zh-cn/common.jsonc @@ -0,0 +1,3 @@ +{ + "title": "EH 下载器" +}