Custom i18n routing handler
This commit is contained in:
parent
f2e433c3f7
commit
f35064f2de
|
@ -20,15 +20,6 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
i18n: {
|
|
||||||
defaultLocale: "en",
|
|
||||||
locales: ["en", "es", "fr", "ja", "pt", "zh"],
|
|
||||||
routing: {
|
|
||||||
prefixDefaultLocale: true,
|
|
||||||
redirectToDefaultLocale: false,
|
|
||||||
strategy: "pathname",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
port: 12499,
|
port: 12499,
|
||||||
host: true,
|
host: true,
|
||||||
|
|
14
package.json
14
package.json
|
@ -12,9 +12,13 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.4.1",
|
"@astrojs/check": "^0.4.1",
|
||||||
"@astrojs/node": "^8.1.0",
|
"@astrojs/node": "^8.2.0",
|
||||||
"astro": "^4.2.5",
|
"@fontsource-variable/murecho": "^5.0.17",
|
||||||
"astro-icon": "^1.0.3",
|
"@fontsource-variable/vollkorn": "^5.0.19",
|
||||||
|
"accept-language": "^3.0.18",
|
||||||
|
"astro": "^4.3.0",
|
||||||
|
"astro-icon": "^1.0.4",
|
||||||
|
"htmx.org": "^1.9.10",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
@ -25,9 +29,9 @@
|
||||||
"astro-meta-tags": "^0.2.1",
|
"astro-meta-tags": "^0.2.1",
|
||||||
"autoprefixer": "^10.4.17",
|
"autoprefixer": "^10.4.17",
|
||||||
"bun-types": "^1.0.25",
|
"bun-types": "^1.0.25",
|
||||||
|
"npm-check-updates": "^16.14.14",
|
||||||
"postcss-preset-env": "^9.3.0",
|
"postcss-preset-env": "^9.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3"
|
||||||
"npm-check-updates": "^16.14.14"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){cursor:default;line-height:1.5;overflow-wrap:break-word;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%}:where(body){margin:0}:where(h1){font-size:2em;margin:0.67em 0}:where(dl,ol,ul):where(dl,ol,ul){margin:0}:where(hr){color:inherit;height:0}:where(nav):where(ol,ul){list-style-type:none;padding:0}:where(nav li)::before{content:"\200B";float:left}:where(pre){font-family:monospace, monospace;font-size:1em;overflow:auto}:where(abbr[title]){text-decoration:underline;text-decoration:underline dotted}:where(b,strong){font-weight:bolder}:where(code,kbd,samp){font-family:monospace, monospace;font-size:1em}:where(small){font-size:80%}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}:where(iframe){border-style:none}:where(svg:not([fill])){fill:currentColor}:where(table){border-collapse:collapse;border-color:inherit;text-indent:0}:where(button,input,select){margin:0}:where(button,[type="button" i],[type="reset" i],[type="submit" i]){-webkit-appearance:button}:where(fieldset){border:1px solid #a0a0a0}:where(progress){vertical-align:baseline}:where(textarea){margin:0;resize:vertical}:where([type="search" i]){-webkit-appearance:textfield;outline-offset:-2px}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}::-webkit-input-placeholder{color:inherit;opacity:0.54}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}:where(dialog){background-color:white;border:solid;color:black;height:-moz-fit-content;height:fit-content;left:0;margin:auto;padding:1em;position:absolute;right:0;width:-moz-fit-content;width:fit-content}:where(dialog:not([open])){display:none}:where(details > summary:first-of-type){display:list-item}:where([aria-busy="true" i]){cursor:progress}:where([aria-controls]){cursor:pointer}:where([aria-disabled="true" i],[disabled]){cursor:not-allowed}:where([aria-hidden="false" i][hidden]){display:initial}:where([aria-hidden="false" i][hidden]:not(:focus)){clip:rect(0, 0, 0, 0);position:absolute}
|
|
|
@ -9,17 +9,45 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { withTitle, class: className } = Astro.props;
|
const { withTitle, class: className } = Astro.props;
|
||||||
const { t } = await getI18n(Astro.currentLocale!);
|
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||||
|
|
||||||
|
const { currentCurrency } = Astro.locals;
|
||||||
---
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- HTML ------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
<Tooltip trigger="click" class={className}>
|
<Tooltip trigger="click" class={className}>
|
||||||
<Fragment slot="tooltip-content">
|
<div id="content" slot="tooltip-content">
|
||||||
<a href="?action-currency=usd">USD</a>
|
<a
|
||||||
<a href="?action-currency=eur">EUR</a>
|
class:list={{ current: currentCurrency === "usd" }}
|
||||||
</Fragment>
|
href="?action-currency=usd">USD</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class:list={{ current: currentCurrency === "eur" }}
|
||||||
|
href="?action-currency=eur">EUR</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="material-symbols:currency-exchange"
|
icon="material-symbols:currency-exchange"
|
||||||
title={withTitle ? "USD" : undefined}
|
title={withTitle ? currentCurrency.toUpperCase() : undefined}
|
||||||
ariaLabel={t("header.topbar.currency.tooltip")}
|
ariaLabel={t("header.topbar.currency.tooltip")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- CSS -------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
& > .current {
|
||||||
|
color: var(--color-base-750);
|
||||||
|
text-decoration: underline 0.08em var(--color-base-650);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -7,7 +7,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { withLinks } = Astro.props;
|
const { withLinks } = Astro.props;
|
||||||
const { t } = await getI18n(Astro.currentLocale!);
|
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||||
|
|
||||||
const discordLabel = `${t("footer.socials.discord.title")} - ${t(
|
const discordLabel = `${t("footer.socials.discord.title")} - ${t(
|
||||||
"footer.socials.discord.subtitle"
|
"footer.socials.discord.subtitle"
|
||||||
|
@ -222,6 +222,7 @@ const contactLabel = `${t("footer.socials.contact.title")} - ${t(
|
||||||
@media (max-width: 35rem) {
|
@media (max-width: 35rem) {
|
||||||
grid-template-areas: "socials" "license";
|
grid-template-areas: "socials" "license";
|
||||||
border-left: unset;
|
border-left: unset;
|
||||||
|
padding-left: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ const userAgent = Astro.request.headers.get("user-agent") ?? "";
|
||||||
const parser = new UAParser(userAgent);
|
const parser = new UAParser(userAgent);
|
||||||
const isIOS = parser.getOS().name === "iOS";
|
const isIOS = parser.getOS().name === "iOS";
|
||||||
|
|
||||||
const prefTheme = Astro.cookies.get("al_pref_theme")?.value;
|
const { currentTheme } = Astro.locals;
|
||||||
|
|
||||||
/* -------------------------------------------- HTML -------------------------------------------- */
|
/* -------------------------------------------- HTML -------------------------------------------- */
|
||||||
---
|
---
|
||||||
|
@ -19,9 +19,9 @@ const prefTheme = Astro.cookies.get("al_pref_theme")?.value;
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
class:list={{
|
class:list={{
|
||||||
"manual-theme": prefTheme !== undefined,
|
"manual-theme": currentTheme !== "auto",
|
||||||
"light-theme": prefTheme === "light",
|
"light-theme": currentTheme === "light",
|
||||||
"dark-theme": prefTheme === "dark",
|
"dark-theme": currentTheme === "dark",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
|
@ -251,10 +251,7 @@ const prefTheme = Astro.cookies.get("al_pref_theme")?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding-top: clamp(12px, 3vmin, 24px);
|
padding: clamp(12px, 3vmin, 24px) clamp(24px, 4vw, 64px);
|
||||||
padding-left: clamp(24px, 4vw, 64px);
|
|
||||||
padding-right: clamp(24px, 4vw, 64px);
|
|
||||||
padding-bottom: clamp(24px, 6vmin, 48px);
|
|
||||||
min-height: 100vb;
|
min-height: 100vb;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,31 +1,55 @@
|
||||||
---
|
---
|
||||||
import astroConfig from "astro.config";
|
|
||||||
import Button from "components/Button.astro";
|
import Button from "components/Button.astro";
|
||||||
import Tooltip from "components/Tooltip.astro";
|
import Tooltip from "components/Tooltip.astro";
|
||||||
import { getI18n } from "translations/translations";
|
import { getI18n, locales } from "translations/translations";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
withTitle?: boolean | undefined;
|
withTitle?: boolean | undefined;
|
||||||
class?: string | undefined;
|
class?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { withTitle, class:className } = Astro.props;
|
const { withTitle, class: className } = Astro.props;
|
||||||
|
|
||||||
const currentLocate = Astro.currentLocale ?? "en";
|
const { currentLocale } = Astro.locals;
|
||||||
const { t } = await getI18n(currentLocate);
|
const { t } = await getI18n(currentLocale);
|
||||||
---
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- HTML ------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
<Tooltip trigger="click" class={className}>
|
<Tooltip trigger="click" class={className}>
|
||||||
<Fragment slot="tooltip-content">
|
<div id="content" slot="tooltip-content">
|
||||||
{
|
{
|
||||||
astroConfig.i18n?.locales.map((locale) => (
|
locales.map((locale) => (
|
||||||
<a href={`?action-lang=${locale}`}>{locale.toString().toUpperCase()}</a>
|
<a
|
||||||
|
class:list={{ current: locale === currentLocale }}
|
||||||
|
href={`?action-lang=${locale}`}
|
||||||
|
>
|
||||||
|
{locale.toString().toUpperCase()}
|
||||||
|
</a>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</Fragment>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="material-symbols:translate"
|
icon="material-symbols:translate"
|
||||||
title={withTitle ? currentLocate.toUpperCase() : undefined}
|
title={withTitle ? currentLocale.toUpperCase() : undefined}
|
||||||
ariaLabel={t("header.topbar.language.tooltip")}
|
ariaLabel={t("header.topbar.language.tooltip")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- CSS -------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
& > .current {
|
||||||
|
color: var(--color-base-750);
|
||||||
|
text-decoration: underline 0.08em var(--color-base-650);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3,15 +3,29 @@ import Button from "components/Button.astro";
|
||||||
import Tooltip from "components/Tooltip.astro";
|
import Tooltip from "components/Tooltip.astro";
|
||||||
import { getI18n } from "translations/translations";
|
import { getI18n } from "translations/translations";
|
||||||
|
|
||||||
const { t } = await getI18n(Astro.currentLocale!);
|
const { currentLocale, currentTheme } = Astro.locals;
|
||||||
|
const { t } = await getI18n(currentLocale);
|
||||||
---
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- HTML ------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
<Tooltip trigger="click">
|
<Tooltip trigger="click">
|
||||||
<Fragment slot="tooltip-content">
|
<div id="content" slot="tooltip-content">
|
||||||
<a href="?action-theme=dark">Dark</a>
|
<a
|
||||||
<a href="?action-theme=auto">Auto</a>
|
class:list={{ current: currentTheme === "dark" }}
|
||||||
<a href="?action-theme=light">Light</a>
|
href="?action-theme=dark">{t("header.topbar.theme.dark")}</a
|
||||||
</Fragment>
|
>
|
||||||
|
<a
|
||||||
|
class:list={{ current: currentTheme === "auto" }}
|
||||||
|
href="?action-theme=auto">{t("header.topbar.theme.auto")}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class:list={{ current: currentTheme === "light" }}
|
||||||
|
href="?action-theme=light">{t("header.topbar.theme.light")}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
class="when-light-theme"
|
class="when-light-theme"
|
||||||
icon="material-symbols:sunny-outline"
|
icon="material-symbols:sunny-outline"
|
||||||
|
@ -23,3 +37,19 @@ const { t } = await getI18n(Astro.currentLocale!);
|
||||||
ariaLabel={t("header.topbar.theme.tooltip")}
|
ariaLabel={t("header.topbar.theme.tooltip")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{
|
||||||
|
/* ------------------------------------------- CSS -------------------------------------------- */
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#content {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
& > .current {
|
||||||
|
color: var(--color-base-750);
|
||||||
|
text-decoration: underline 0.08em var(--color-base-650);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -11,7 +11,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { breadcrumb } = Astro.props;
|
const { breadcrumb } = Astro.props;
|
||||||
const { t } = await getI18n(Astro.currentLocale!);
|
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,2 +1,11 @@
|
||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
|
||||||
|
declare namespace App {
|
||||||
|
interface Locals {
|
||||||
|
currentLocale: import("translations/translations").Locale
|
||||||
|
currentTheme: "dark" | "auto" | "light"
|
||||||
|
currentCurrency: "usd" | "eur"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,18 @@
|
||||||
import type { AstroCookies } from "astro";
|
|
||||||
import { defineMiddleware, sequence } from "astro:middleware";
|
import { defineMiddleware, sequence } from "astro:middleware";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import astroConfig from "astro.config";
|
import {
|
||||||
|
defaultLocale,
|
||||||
|
getCookiePreferredLocale,
|
||||||
|
getCurrentLocale,
|
||||||
|
getPreferredLocale,
|
||||||
|
} from "translations/translations";
|
||||||
|
|
||||||
const cookieThemeSchema = z.enum(["dark", "light", "auto"]);
|
const cookieThemeSchema = z.enum(["dark", "light", "auto"]);
|
||||||
|
|
||||||
const getAbsoluteLocaleUrl = (locale: string, url: string) =>
|
const getAbsoluteLocaleUrl = (locale: string, url: string) =>
|
||||||
`/${locale}${url}`;
|
`/${locale}${url}`;
|
||||||
|
|
||||||
const redirection = (
|
const redirect = (
|
||||||
redirectURL: string,
|
redirectURL: string,
|
||||||
headers: Record<string, string> = {}
|
headers: Record<string, string> = {}
|
||||||
): Response => {
|
): Response => {
|
||||||
|
@ -19,30 +23,23 @@ const redirection = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const langMiddleware = defineMiddleware(
|
const localeNegotiator = defineMiddleware(
|
||||||
({ cookies, preferredLocale, currentLocale, url }, next) => {
|
({ cookies, url, request }, next) => {
|
||||||
const cookiePreferredLocale = getCookiePreferredLocale(cookies);
|
const currentLocale = getCurrentLocale(url.pathname);
|
||||||
const actionLang = url.searchParams.get("action-lang");
|
const preferredLocale = getPreferredLocale(request);
|
||||||
|
|
||||||
if (!currentLocale) {
|
if (url.pathname.startsWith("/api/")) {
|
||||||
currentLocale = cookiePreferredLocale ?? preferredLocale ?? "en";
|
return next();
|
||||||
const redirectURL = getAbsoluteLocaleUrl(currentLocale, url.pathname);
|
|
||||||
return redirection(redirectURL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actionLang) {
|
const cookiePreferredLocale = getCookiePreferredLocale(cookies);
|
||||||
const pathnameWithoutLocale = url.pathname.substring(
|
|
||||||
currentLocale.length + 1
|
if (!currentLocale) {
|
||||||
);
|
|
||||||
const redirectURL = getAbsoluteLocaleUrl(
|
const redirectURL = getAbsoluteLocaleUrl(
|
||||||
actionLang,
|
cookiePreferredLocale ?? preferredLocale ?? defaultLocale,
|
||||||
pathnameWithoutLocale
|
url.pathname
|
||||||
);
|
);
|
||||||
return redirection(redirectURL, {
|
return redirect(redirectURL);
|
||||||
"Set-Cookie": `al_pref_languages=${JSON.stringify([
|
|
||||||
actionLang,
|
|
||||||
])}; Path=/`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cookiePreferredLocale) {
|
if (cookiePreferredLocale) {
|
||||||
|
@ -54,7 +51,7 @@ export const langMiddleware = defineMiddleware(
|
||||||
cookiePreferredLocale,
|
cookiePreferredLocale,
|
||||||
pathnameWithoutLocale
|
pathnameWithoutLocale
|
||||||
);
|
);
|
||||||
return redirection(redirectURL);
|
return redirect(redirectURL);
|
||||||
}
|
}
|
||||||
} else if (preferredLocale) {
|
} else if (preferredLocale) {
|
||||||
if (preferredLocale !== currentLocale) {
|
if (preferredLocale !== currentLocale) {
|
||||||
|
@ -65,60 +62,80 @@ export const langMiddleware = defineMiddleware(
|
||||||
preferredLocale,
|
preferredLocale,
|
||||||
pathnameWithoutLocale
|
pathnameWithoutLocale
|
||||||
);
|
);
|
||||||
return redirection(redirectURL);
|
return redirect(redirectURL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const headersMiddleware = defineMiddleware(
|
const handleActionsSearchParams = defineMiddleware(async ({ url }, next) => {
|
||||||
async ({ currentLocale, url }, next) => {
|
// TODO: Verify locale typing
|
||||||
const actionTheme = url.searchParams.get("action-theme");
|
const actionLang = url.searchParams.get("action-lang");
|
||||||
|
if (actionLang) {
|
||||||
|
const currentLocale = getCurrentLocale(url.pathname);
|
||||||
|
const pathnameWithoutLocale = currentLocale
|
||||||
|
? url.pathname.substring(currentLocale.length + 1)
|
||||||
|
: url.pathname;
|
||||||
|
const redirectURL = getAbsoluteLocaleUrl(actionLang, pathnameWithoutLocale);
|
||||||
|
return redirect(redirectURL, {
|
||||||
|
"Set-Cookie": `al_pref_languages=${JSON.stringify([actionLang])}; Path=/`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verify currency typing
|
||||||
|
const actionCurrency = url.searchParams.get("action-currency");
|
||||||
|
if (actionCurrency) {
|
||||||
|
return redirect(url.pathname, {
|
||||||
|
"Set-Cookie": `al_pref_currency=${JSON.stringify(
|
||||||
|
actionCurrency
|
||||||
|
)}; Path=/`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTheme = url.searchParams.get("action-theme");
|
||||||
const verifiedActionTheme = cookieThemeSchema.safeParse(actionTheme);
|
const verifiedActionTheme = cookieThemeSchema.safeParse(actionTheme);
|
||||||
|
|
||||||
if (verifiedActionTheme.success) {
|
if (verifiedActionTheme.success) {
|
||||||
url.searchParams.delete("action-theme");
|
url.searchParams.delete("action-theme");
|
||||||
if (verifiedActionTheme.data === "auto") {
|
if (verifiedActionTheme.data === "auto") {
|
||||||
return redirection(url.toString(), {
|
return redirect(url.pathname, {
|
||||||
"Set-Cookie": `al_pref_theme=; Path=/; Expires=${new Date(0).toUTCString()}`,
|
"Set-Cookie": `al_pref_theme=; Path=/; Expires=${new Date(
|
||||||
|
0
|
||||||
|
).toUTCString()}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return redirection(url.toString(), {
|
return redirect(url.pathname, {
|
||||||
"Set-Cookie": `al_pref_theme=${verifiedActionTheme.data}; Path=/`,
|
"Set-Cookie": `al_pref_theme=${verifiedActionTheme.data}; Path=/`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const addContentLanguageResponseHeader = defineMiddleware(
|
||||||
|
async ({ url }, next) => {
|
||||||
|
const currentLocale = getCurrentLocale(url.pathname);
|
||||||
|
|
||||||
const response = await next();
|
const response = await next();
|
||||||
if (currentLocale) {
|
if (response.status === 200 && currentLocale) {
|
||||||
response.headers.set("Content-Language", currentLocale);
|
response.headers.set("Content-Language", currentLocale);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const onRequest = sequence(headersMiddleware, langMiddleware);
|
const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, next) => {
|
||||||
|
locals.currentLocale = getCurrentLocale(url.pathname) ?? "en";
|
||||||
|
locals.currentCurrency = cookies.get("al_pref_currency")?.value ?? "usd"
|
||||||
|
locals.currentTheme = cookies.get("al_pref_theme")?.value ?? "auto"
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
const getCookiePreferredLocale = (
|
export const onRequest = sequence(
|
||||||
cookies: AstroCookies
|
addContentLanguageResponseHeader,
|
||||||
): string | undefined => {
|
handleActionsSearchParams,
|
||||||
const alPrefLanguages = cookies.get("al_pref_languages");
|
localeNegotiator,
|
||||||
|
provideLocalsToRequest
|
||||||
try {
|
);
|
||||||
const json = alPrefLanguages?.json();
|
|
||||||
const result = z.array(z.string()).nonempty().safeParse(json);
|
|
||||||
if (result.success) {
|
|
||||||
for (const value of result.data) {
|
|
||||||
if (astroConfig.i18n?.locales.includes(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
---
|
---
|
||||||
import AppLayout from "components/AppLayout/AppLayout.astro";
|
import AppLayout from "components/AppLayout/AppLayout.astro";
|
||||||
import FolderCard from "./_components/FolderCard.astro";
|
import FolderCard from "./_components/FolderCard.astro";
|
||||||
|
import { getI18n } from "translations/translations";
|
||||||
|
|
||||||
|
const {getLocalizedUrl} = await getI18n(Astro.locals.currentLocale)
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -19,62 +22,62 @@ import FolderCard from "./_components/FolderCard.astro";
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Games"
|
title="Games"
|
||||||
icon="material-symbols:stadia-controller-outline"
|
icon="material-symbols:stadia-controller-outline"
|
||||||
href="/drakengard/games"
|
href={getLocalizedUrl("/drakengard/games")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Guidebooks"
|
title="Guidebooks"
|
||||||
icon="material-symbols:menu-book-outline"
|
icon="material-symbols:menu-book-outline"
|
||||||
href="/drakengard/guidebooks"
|
href={getLocalizedUrl("/drakengard/guidebooks")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Manga"
|
title="Manga"
|
||||||
icon="material-symbols:menu-book-outline"
|
icon="material-symbols:menu-book-outline"
|
||||||
href="/drakengard/manga"
|
href={getLocalizedUrl("/drakengard/manga")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Novels"
|
title="Novels"
|
||||||
icon="material-symbols:menu-book-outline"
|
icon="material-symbols:menu-book-outline"
|
||||||
href="/drakengard/novels"
|
href={getLocalizedUrl("/drakengard/novels")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Novellas"
|
title="Novellas"
|
||||||
icon="material-symbols:menu-book-outline"
|
icon="material-symbols:menu-book-outline"
|
||||||
href="/drakengard/novellas "
|
href={getLocalizedUrl("/drakengard/novellas")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Characters"
|
title="Characters"
|
||||||
icon="material-symbols:person-outline"
|
icon="material-symbols:person-outline"
|
||||||
href="/drakengard/characters"
|
href={getLocalizedUrl("/drakengard/characters")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Places"
|
title="Places"
|
||||||
icon="material-symbols:location-on-outline"
|
icon="material-symbols:location-on-outline"
|
||||||
href="/drakengard/places"
|
href={getLocalizedUrl("/drakengard/places")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Enemies"
|
title="Enemies"
|
||||||
icon="material-symbols:sentiment-extremely-dissatisfied-outline"
|
icon="material-symbols:sentiment-extremely-dissatisfied-outline"
|
||||||
href="/drakengard/enemies"
|
href={getLocalizedUrl("/drakengard/enemies")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Terms"
|
title="Terms"
|
||||||
icon="material-symbols:indeterminate-question-box"
|
icon="material-symbols:indeterminate-question-box"
|
||||||
href="/drakengard/terms"
|
href={getLocalizedUrl("/drakengard/terms")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Weapons"
|
title="Weapons"
|
||||||
icon="material-symbols:swords-outline"
|
icon="material-symbols:swords-outline"
|
||||||
href="/drakengard/weapons"
|
href={getLocalizedUrl("/drakengard/weapons")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="Timeline"
|
title="Timeline"
|
||||||
icon="material-symbols:calendar-month-outline"
|
icon="material-symbols:calendar-month-outline"
|
||||||
href="/drakengard/timeline"
|
href={getLocalizedUrl("/drakengard/timeline")}
|
||||||
/>
|
/>
|
||||||
<FolderCard
|
<FolderCard
|
||||||
title="News"
|
title="News"
|
||||||
icon="material-symbols:newspaper"
|
icon="material-symbols:newspaper"
|
||||||
href="/drakengard/news"
|
href={getLocalizedUrl("/drakengard/news")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import LinkCard from "../_components/LinkCard.astro";
|
||||||
import CategoryCard from "../_components/CategoryCard.astro";
|
import CategoryCard from "../_components/CategoryCard.astro";
|
||||||
import { getI18n } from "../../../translations/translations";
|
import { getI18n } from "../../../translations/translations";
|
||||||
|
|
||||||
const { t, getLocalizedUrl } = await getI18n(Astro.currentLocale!);
|
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -266,8 +266,8 @@ const { t, getLocalizedUrl } = await getI18n(Astro.currentLocale!);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-top: -0.5em;
|
margin-top: -0.5em;
|
||||||
font-size: 21.5px;
|
font-size: 21px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -309,7 +309,7 @@ const { t, getLocalizedUrl } = await getI18n(Astro.currentLocale!);
|
||||||
& > section {
|
& > section {
|
||||||
& > h2 {
|
& > h2 {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > p {
|
& > p {
|
||||||
|
|
|
@ -13,6 +13,9 @@
|
||||||
|
|
||||||
"header.topbar.search.tooltip": "Search on this website",
|
"header.topbar.search.tooltip": "Search on this website",
|
||||||
"header.topbar.theme.tooltip": "Switch between dark/light mode",
|
"header.topbar.theme.tooltip": "Switch between dark/light mode",
|
||||||
|
"header.topbar.theme.dark": "Dark",
|
||||||
|
"header.topbar.theme.auto": "Auto",
|
||||||
|
"header.topbar.theme.light": "Light",
|
||||||
"header.topbar.language.tooltip": "Select preferred language",
|
"header.topbar.language.tooltip": "Select preferred language",
|
||||||
"header.topbar.currency.tooltip": "Select preferred currency",
|
"header.topbar.currency.tooltip": "Select preferred currency",
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,45 @@
|
||||||
{
|
{
|
||||||
|
"global.siteName": "Accord’s Library",
|
||||||
|
"global.siteSubtitle": "Discover • Archive • Translate • Analyze",
|
||||||
|
"home.title": "Accueil",
|
||||||
|
"home.description": "Notre objectif est d'archiver et de traduire toutes les œuvres de <strong>Yoko Taro</strong>.<br />Yoko Taro est une réalisatrice et scénariste de jeux vidéo japonaise. Il est surtout connu pour son implication dans les séries <strong>NieR</strong> et <strong>Drakengard</strong>. Pour compléter ses jeux, Yoko Taro aime publier du matériel annexe sous forme de livres, d'animes, de mangas, de livres audio, de romans, voire de pièces de théâtre.<br />Ces médias peuvent être très difficiles à trouver. Son travail remonte à 2003. La majeure partie a été publiée uniquement en japonais, et parfois en quantité limitée. Voici donc ce que nous faisons ici : <strong>découvrir, archiver, traduire et analyser</strong>.",
|
||||||
|
"home.aboutUsButton": "En savoir plus sur nous",
|
||||||
|
"home.librarySection.title": "La bibliothèque",
|
||||||
|
"home.librarySection.description": "Vous trouverez ici une liste des IP sur lesquelles Yoko Taro a travaillé. Sélectionnez-en un pour découvrir tous les médias/contenus/articles liés à cette IP. <strong>Attention, il peut y avoir des spoilers.</strong>",
|
||||||
|
"home.moreSection.title": "Plus de contenu",
|
||||||
|
"home.moreSection.description": "Les séries NieR et Drakengard partagent une chronologie commune que vous pouvez explorer via le lien ci-dessous. Nous avons également rassemblé des milliers d’œuvres d’art officielles, de vidéos et de ressources Web notables. <strong>Attention, il peut y avoir des spoilers.</strong>",
|
||||||
|
"home.linksSection.title": "Liens",
|
||||||
|
"home.linksSection.description": "Avez-vous une <strong>question</strong> ? Vous souhaitez partager quelque chose avec notre <strong>communauté</strong> ? Êtes-vous intéressé à <strong>contribuer</strong> à ce projet ? Quoi qu’il en soit, vous devriez trouver ce que vous cherchez sur les liens suivants.",
|
||||||
|
|
||||||
|
"header.topbar.search.tooltip": "Rechercher sur ce site",
|
||||||
|
"header.topbar.theme.tooltip": "Basculer entre le mode sombre/clair",
|
||||||
|
"header.topbar.theme.dark": "Sombre",
|
||||||
|
"header.topbar.theme.auto": "Auto",
|
||||||
|
"header.topbar.theme.light": "Clair",
|
||||||
|
"header.topbar.language.tooltip": "Sélectionnez la langue préférée",
|
||||||
|
"header.topbar.currency.tooltip": "Sélectionnez la devise préférée",
|
||||||
|
|
||||||
"footer.links.home.title": "Accueil",
|
"footer.links.home.title": "Accueil",
|
||||||
"footer.links.timeline.title": "Chronologie",
|
"footer.links.timeline.title": "Chronologie",
|
||||||
"footer.links.timeline.subtitle": "{{eraCount}} époques, {{eventCount}} évenements",
|
"footer.links.timeline.subtitle": "{{ eraCount }} époque{{ eraCount+,>1{s} }}, {{ eventCount }} évenement{{ eventCount+,>1{s} }}",
|
||||||
"footer.links.gallery.title": "Gallerie",
|
"footer.links.gallery.title": "Gallerie",
|
||||||
"footer.links.gallery.subtitle": "{{count}} images",
|
"footer.links.gallery.subtitle": "{{ count }} image{{ count+,>1{s} }}",
|
||||||
"footer.links.videos.title": "Vidéos",
|
"footer.links.videos.title": "Vidéos",
|
||||||
"footer.links.videos.subtitle": "{{count}} vidéos",
|
"footer.links.videos.subtitle": "{{ count }} vidéo{{ count+,>1{s} }}",
|
||||||
"footer.links.webArchives.title": "Archives web",
|
"footer.links.webArchives.title": "Archives web",
|
||||||
"footer.links.webArchives.subtitle": "{{count}} archives"
|
"footer.links.webArchives.subtitle": "{{ count }} archive{{ count+,>1{s} }}",
|
||||||
|
|
||||||
|
"footer.socials.discord.title": "Discord",
|
||||||
|
"footer.socials.discord.subtitle": "Rejoindre la communauté",
|
||||||
|
"footer.socials.twitter.title": "Twitter",
|
||||||
|
"footer.socials.twitter.subtitle": "Connaitre les dernières nouvelles",
|
||||||
|
"footer.socials.github.title": "GitHub",
|
||||||
|
"footer.socials.github.subtitle": "Rejoindre l'équipe technique",
|
||||||
|
"footer.socials.contact.title": "Contact",
|
||||||
|
"footer.socials.contact.subtitle": "Nous contacter par email",
|
||||||
|
|
||||||
|
"footer.license.description": "Le contenu de ce site Web est disponible sous <a href=\"https://creativecommons.org/licenses/by-sa/4.0/\">CC-BY-SA</a>, sauf indication contraire.",
|
||||||
|
"footer.license.icons.tooltip": "Licence CC-BY-SA 4.0",
|
||||||
|
|
||||||
|
"footer.disclaimer": "<strong>Accord’s Library</strong> n'est ni affiliée ni approuvée par <strong>SQUARE ENIX CO. LTD</strong>. Tous les éléments du jeu et le matériel promotionnel appartiennent à <strong>© SQUARE ENIX CO. LTD</strong>."
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"global.siteName": "アコールの図書館"
|
||||||
|
}
|
|
@ -1,13 +1,20 @@
|
||||||
|
import type { AstroCookies } from "astro";
|
||||||
import en from "./en.json";
|
import en from "./en.json";
|
||||||
|
import fr from "./fr.json";
|
||||||
|
import ja from "./ja.json"
|
||||||
|
|
||||||
|
import acceptLanguage from 'accept-language';
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
type WordingKeys = keyof typeof en;
|
type WordingKeys = keyof typeof en;
|
||||||
|
const translationFiles: Record<string, Record<WordingKeys, string>> = {
|
||||||
|
en,
|
||||||
|
fr,
|
||||||
|
ja
|
||||||
|
};
|
||||||
|
|
||||||
export const getI18n = async (locale: string) => {
|
export const getI18n = async (locale: string) => {
|
||||||
const file = Bun.file(`./translations/${locale}.json`, {
|
const translations = translationFiles[locale];
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const content = await file.text();
|
|
||||||
const translations: Record<string, string> = JSON.parse(content);
|
|
||||||
|
|
||||||
const formatWithValues = (
|
const formatWithValues = (
|
||||||
templateName: string,
|
templateName: string,
|
||||||
|
@ -105,7 +112,7 @@ export const getI18n = async (locale: string) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
t: (key: WordingKeys, values: Record<string, any> = {}): string => {
|
t: (key: WordingKeys, values: Record<string, any> = {}): string => {
|
||||||
if (key in translations) {
|
if (translations && key in translations) {
|
||||||
return formatWithValues(key, translations[key]!, values);
|
return formatWithValues(key, translations[key]!, values);
|
||||||
}
|
}
|
||||||
return `«${key}»`;
|
return `«${key}»`;
|
||||||
|
@ -132,3 +139,46 @@ const limitMatchToBalanceCurlyBraces = (
|
||||||
}
|
}
|
||||||
return match.substring(0, index);
|
return match.substring(0, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const locales = ["en", "es", "fr", "ja", "pt", "zh"] as const;
|
||||||
|
acceptLanguage.languages([...locales]);
|
||||||
|
|
||||||
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
|
export const defaultLocale: Locale = "en";
|
||||||
|
|
||||||
|
export const getCurrentLocale = (pathname: string): Locale | undefined => {
|
||||||
|
for (const locale of locales) {
|
||||||
|
if (pathname.startsWith(`/${locale}`)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreferredLocale = (request: Request): Locale | undefined => {
|
||||||
|
return acceptLanguage.get(request.headers.get("Accept-Language")) as Locale | null ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCookiePreferredLocale = (
|
||||||
|
cookies: AstroCookies
|
||||||
|
): string | undefined => {
|
||||||
|
const alPrefLanguages = cookies.get("al_pref_languages");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = alPrefLanguages?.json();
|
||||||
|
const result = z.array(z.string()).nonempty().safeParse(json);
|
||||||
|
if (result.success) {
|
||||||
|
for (const value of result.data) {
|
||||||
|
if (locales.includes(value as Locale)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue