Internationalization improvements for the website (#7515)

This commit is contained in:
Elian Doran 2025-10-25 23:46:43 +03:00 committed by GitHub
commit 252f8ccb1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 398 additions and 200 deletions

View File

@ -5,6 +5,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "vitest",
"preview": "pnpm build && vite preview" "preview": "pnpm build && vite preview"
}, },
"dependencies": { "dependencies": {

View File

@ -39,6 +39,7 @@
"web_clipper_content": "Grab web pages (or screenshots) and place them directly into Trilium using the web clipper browser extension." "web_clipper_content": "Grab web pages (or screenshots) and place them directly into Trilium using the web clipper browser extension."
}, },
"note_types": { "note_types": {
"title": "Multiple ways to represent your information",
"text_title": "Text notes", "text_title": "Text notes",
"text_description": "The notes are edited using a visual (WYSIWYG) editor, with support for tables, images, math expressions, code blocks with syntax highlighting. Quickly format the text using Markdown-like syntax or using slash commands.", "text_description": "The notes are edited using a visual (WYSIWYG) editor, with support for tables, images, math expressions, code blocks with syntax highlighting. Quickly format the text using Markdown-like syntax or using slash commands.",
"code_title": "Code notes", "code_title": "Code notes",
@ -65,6 +66,7 @@
"api_description": "Interact with Trilium programatically using its builtin REST API." "api_description": "Interact with Trilium programatically using its builtin REST API."
}, },
"collections": { "collections": {
"title": "Collections",
"calendar_title": "Calendar", "calendar_title": "Calendar",
"calendar_description": "Organize your personal or professional events using a calendar, with support for all-day and multi-day events. See your events at a glance with the week, month and year views. Easy interaction to add or drag events.", "calendar_description": "Organize your personal or professional events using a calendar, with support for all-day and multi-day events. See your events at a glance with the week, month and year views. Easy interaction to add or drag events.",
"table_title": "Table", "table_title": "Table",
@ -106,6 +108,11 @@
"linux_small": "for Linux", "linux_small": "for Linux",
"more_platforms": "More platforms & server setup" "more_platforms": "More platforms & server setup"
}, },
"header": {
"get-started": "Get started",
"documentation": "Documentation",
"support-us": "Support us"
},
"footer": { "footer": {
"copyright_and_the": " and the ", "copyright_and_the": " and the ",
"copyright_community": "community" "copyright_community": "community"

View File

@ -106,6 +106,11 @@
"linux_small": "pentru Linux", "linux_small": "pentru Linux",
"more_platforms": "Mai multe platforme și instalarea server-ului" "more_platforms": "Mai multe platforme și instalarea server-ului"
}, },
"header": {
"get-started": "Primii pași",
"documentation": "Documentație",
"support-us": "Sprijină-ne"
},
"footer": { "footer": {
"copyright_and_the": " și ", "copyright_and_the": " și ",
"copyright_community": "comunitatea" "copyright_community": "comunitatea"

View File

@ -1,7 +1,7 @@
import { ComponentChildren, HTMLAttributes } from "preact"; import { ComponentChildren, HTMLAttributes } from "preact";
import { Link } from "./Button.js"; import { Link } from "./Button.js";
import Icon from "./Icon.js"; import Icon from "./Icon.js";
import { t } from "../i18n.js"; import { useTranslation } from "react-i18next";
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> { interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title: ComponentChildren; title: ComponentChildren;
@ -13,6 +13,8 @@ interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
} }
export default function Card({ title, children, imageUrl, iconSvg, className, moreInfoUrl, ...restProps }: CardProps) { export default function Card({ title, children, imageUrl, iconSvg, className, moreInfoUrl, ...restProps }: CardProps) {
const { t } = useTranslation();
return ( return (
<div className={`card ${className}`} {...restProps}> <div className={`card ${className}`} {...restProps}>
{imageUrl && <img class="image" src={imageUrl} loading="lazy" />} {imageUrl && <img class="image" src={imageUrl} loading="lazy" />}

View File

@ -3,18 +3,21 @@ import "./DownloadButton.css";
import Button from "./Button.js"; import Button from "./Button.js";
import downloadIcon from "../assets/boxicons/bx-arrow-in-down-square-half.svg?raw"; import downloadIcon from "../assets/boxicons/bx-arrow-in-down-square-half.svg?raw";
import packageJson from "../../../../package.json" with { type: "json" }; import packageJson from "../../../../package.json" with { type: "json" };
import { useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useState } from "preact/hooks";
import { t } from "../i18n.js"; import { useTranslation } from "react-i18next";
import { LocaleContext } from "../index.js";
interface DownloadButtonProps { interface DownloadButtonProps {
big?: boolean; big?: boolean;
} }
export default function DownloadButton({ big }: DownloadButtonProps) { export default function DownloadButton({ big }: DownloadButtonProps) {
const locale = useContext(LocaleContext);
const { t } = useTranslation();
const [ recommendedDownload, setRecommendedDownload ] = useState<RecommendedDownload | null>(); const [ recommendedDownload, setRecommendedDownload ] = useState<RecommendedDownload | null>();
useEffect(() => { useEffect(() => {
getRecommendedDownload()?.then(setRecommendedDownload); getRecommendedDownload(t)?.then(setRecommendedDownload);
}, []); }, [ t ]);
return (recommendedDownload && return (recommendedDownload &&
<> <>
@ -35,7 +38,7 @@ export default function DownloadButton({ big }: DownloadButtonProps) {
) : ( ) : (
<Button <Button
className={`download-button desktop-only ${big ? "big" : ""}`} className={`download-button desktop-only ${big ? "big" : ""}`}
href="/get-started/" href={`/${locale}/get-started/`}
iconSvg={downloadIcon} iconSvg={downloadIcon}
text={<> text={<>
{t("download_now.text")} {t("download_now.text")}

View File

@ -5,17 +5,26 @@ footer {
color: var(--muted-color); color: var(--muted-color);
font-size: 0.8em; font-size: 0.8em;
.content-wrapper { .row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-direction: column-reverse; flex-direction: column-reverse;
gap: 2em; gap: 2em;
margin-bottom: 1em;
@media (min-width: 720px) { @media (min-width: 720px) {
flex-direction: row; flex-direction: row;
} }
} }
nav.languages {
flex-grow: 1;
justify-content: center;
flex-wrap: wrap;
display: flex;
gap: 0.5em 1em;
}
} }
.social-buttons { .social-buttons {

View File

@ -5,24 +5,46 @@ import githubDiscussionsIcon from "../assets/boxicons/bx-discussion.svg?raw";
import matrixIcon from "../assets/boxicons/bx-message-dots.svg?raw"; import matrixIcon from "../assets/boxicons/bx-message-dots.svg?raw";
import redditIcon from "../assets/boxicons/bx-reddit.svg?raw"; import redditIcon from "../assets/boxicons/bx-reddit.svg?raw";
import { Link } from "./Button.js"; import { Link } from "./Button.js";
import { t } from "../i18n"; import { LOCALES, swapLocaleInUrl } from "../i18n";
import { useTranslation } from "react-i18next";
import { useLocation } from "preact-iso";
import { useContext } from "preact/hooks";
import { LocaleContext } from "..";
export default function Footer() { export default function Footer() {
const { t } = useTranslation();
const { url } = useLocation();
const currentLocale = useContext(LocaleContext);
return ( return (
<footer> <footer>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="footer-text"> <div class="row">
© 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br /> <div class="footer-text">
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>. © 2024-2025 <Link href="https://github.com/eliandoran" openExternally>Elian Doran</Link>{t("footer.copyright_and_the")}<Link href="https://github.com/TriliumNext/Trilium/graphs/contributors" openExternally>{t("footer.copyright_community")}</Link>.<br />
© 2017-2024 <Link href="https://github.com/zadam" openExternally>zadam</Link>.
</div>
<SocialButtons />
</div> </div>
<SocialButtons /> <div class="row">
<nav class="languages">
{LOCALES.map(locale => (
locale.id !== currentLocale
? <Link href={swapLocaleInUrl(url, locale.id)}>{locale.name}</Link>
: <span className="active">{locale.name}</span>
))}
</nav>
</div>
</div> </div>
</footer> </footer>
) )
} }
export function SocialButtons({ className, withText }: { className?: string, withText?: boolean }) { export function SocialButtons({ className, withText }: { className?: string, withText?: boolean }) {
const { t } = useTranslation();
return ( return (
<div className={`social-buttons ${className}`}> <div className={`social-buttons ${className}`}>
<SocialButton <SocialButton

View File

@ -1,13 +1,16 @@
import "./Header.css"; import "./Header.css";
import { Link } from "./Button.js"; import { Link } from "./Button.js";
import { SocialButtons, SocialButton } from "./Footer.js"; import { SocialButtons, SocialButton } from "./Footer.js";
import { useEffect, useMemo, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { useLocation } from 'preact-iso'; import { useLocation } from 'preact-iso';
import DownloadButton from './DownloadButton.js'; import DownloadButton from './DownloadButton.js';
import githubIcon from "../assets/boxicons/bx-github.svg?raw"; import githubIcon from "../assets/boxicons/bx-github.svg?raw";
import Icon from "./Icon.js"; import Icon from "./Icon.js";
import logoPath from "../assets/icon-color.svg"; import logoPath from "../assets/icon-color.svg";
import menuIcon from "../assets/boxicons/bx-menu.svg?raw"; import menuIcon from "../assets/boxicons/bx-menu.svg?raw";
import { LocaleContext } from "..";
import { useTranslation } from "react-i18next";
import { swapLocaleInUrl } from "../i18n";
interface HeaderLink { interface HeaderLink {
url: string; url: string;
@ -15,21 +18,26 @@ interface HeaderLink {
external?: boolean; external?: boolean;
} }
const HEADER_LINKS: HeaderLink[] = [
{ url: "/get-started/", text: "Get started" },
{ url: "https://docs.triliumnotes.org/", text: "Documentation", external: true },
{ url: "/support-us/", text: "Support us" }
]
export function Header(props: {repoStargazersCount: number}) { export function Header(props: {repoStargazersCount: number}) {
const { url } = useLocation(); const { url } = useLocation();
const { t } = useTranslation();
const locale = useContext(LocaleContext);
const [ mobileMenuShown, setMobileMenuShown ] = useState(false); const [ mobileMenuShown, setMobileMenuShown ] = useState(false);
const [ headerLinks, setHeaderLinks ] = useState<HeaderLink[]>([]);
useEffect(() => {
setHeaderLinks([
{ url: "/get-started", text: t("header.get-started") },
{ url: "https://docs.triliumnotes.org/", text: t("header.documentation"), external: true },
{ url: "/support-us", text: t("header.support-us") }
]);
}, [ locale, t ]);
return ( return (
<header> <header>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="first-row"> <div class="first-row">
<a class="banner" href="/"> <a class="banner" href={`/${locale}/`}>
<img src={logoPath} width="300" height="300" alt="Trilium Notes logo" />&nbsp;<span>Trilium Notes</span> <img src={logoPath} width="300" height="300" alt="Trilium Notes logo" />&nbsp;<span>Trilium Notes</span>
</a> </a>
@ -46,16 +54,17 @@ export function Header(props: {repoStargazersCount: number}) {
</div> </div>
<nav className={`${mobileMenuShown ? "mobile-shown" : ""}`}> <nav className={`${mobileMenuShown ? "mobile-shown" : ""}`}>
{HEADER_LINKS.map(link => ( {headerLinks.map(link => {
<Link const linkHref = link.external ? link.url : swapLocaleInUrl(link.url, locale);
href={link.url} return (<Link
className={url === link.url ? "active" : ""} href={linkHref}
className={url === linkHref ? "active" : ""}
openExternally={link.external} openExternally={link.external}
onClick={() => { onClick={() => {
setMobileMenuShown(false); setMobileMenuShown(false);
}} }}
>{link.text}</Link> >{link.text}</Link>)
))} })}
<SocialButtons className="mobile-only" withText /> <SocialButtons className="mobile-only" withText />
</nav> </nav>
@ -74,4 +83,4 @@ export function Header(props: {repoStargazersCount: number}) {
</div> </div>
</header> </header>
); );
} }

View File

@ -1,5 +1,5 @@
import { TFunction } from 'i18next';
import rootPackageJson from '../../../package.json' with { type: "json" }; import rootPackageJson from '../../../package.json' with { type: "json" };
import { t } from './i18n';
export type App = "desktop" | "server"; export type App = "desktop" | "server";
@ -34,151 +34,155 @@ export interface RecommendedDownload {
type DownloadMatrix = Record<App, { [ P in Platform ]?: DownloadMatrixEntry }>; type DownloadMatrix = Record<App, { [ P in Platform ]?: DownloadMatrixEntry }>;
// Keep compatibility info inline with https://github.com/electron/electron/blob/main/README.md#platform-support. // Keep compatibility info inline with https://github.com/electron/electron/blob/main/README.md#platform-support.
export const downloadMatrix: DownloadMatrix = { export function getDownloadMatrix(t: TFunction<"translation", undefined>): DownloadMatrix {
desktop: { return {
windows: { desktop: {
title: { windows: {
x64: t("download_helper_desktop_windows.title_x64"), title: {
arm64: t("download_helper_desktop_windows.title_arm64") x64: t("download_helper_desktop_windows.title_x64"),
}, arm64: t("download_helper_desktop_windows.title_arm64")
description: {
x64: t("download_helper_desktop_windows.description_x64"),
arm64: t("download_helper_desktop_windows.description_arm64"),
},
quickStartTitle: t("download_helper_desktop_windows.quick_start"),
quickStartCode: "winget install TriliumNext.Notes",
downloads: {
exe: {
recommended: true,
name: t("download_helper_desktop_windows.download_exe")
}, },
zip: { description: {
name: t("download_helper_desktop_windows.download_zip") x64: t("download_helper_desktop_windows.description_x64"),
arm64: t("download_helper_desktop_windows.description_arm64"),
}, },
scoop: { quickStartTitle: t("download_helper_desktop_windows.quick_start"),
name: t("download_helper_desktop_windows.download_scoop"), quickStartCode: "winget install TriliumNext.Notes",
url: "https://scoop.sh/#/apps?q=trilium&id=7c08bc3c105b9ee5c00dd4245efdea0f091b8a5c" downloads: {
exe: {
recommended: true,
name: t("download_helper_desktop_windows.download_exe")
},
zip: {
name: t("download_helper_desktop_windows.download_zip")
},
scoop: {
name: t("download_helper_desktop_windows.download_scoop"),
url: "https://scoop.sh/#/apps?q=trilium&id=7c08bc3c105b9ee5c00dd4245efdea0f091b8a5c"
}
}
},
linux: {
title: {
x64: t("download_helper_desktop_linux.title_x64"),
arm64: t("download_helper_desktop_linux.title_arm64")
},
description: {
x64: t("download_helper_desktop_linux.description_x64"),
arm64: t("download_helper_desktop_linux.description_arm64"),
},
quickStartTitle: t("download_helper_desktop_linux.quick_start"),
downloads: {
deb: {
recommended: true,
name: t("download_helper_desktop_linux.download_deb")
},
rpm: {
recommended: true,
name: t("download_helper_desktop_linux.download_rpm")
},
flatpak: {
name: t("download_helper_desktop_linux.download_flatpak")
},
zip: {
name: t("download_helper_desktop_linux.download_zip")
},
nixpkgs: {
name: t("download_helper_desktop_linux.download_nixpkgs"),
url: "https://search.nixos.org/packages?query=trilium-next"
},
aur: {
name: t("download_helper_desktop_linux.download_aur"),
url: "https://aur.archlinux.org/packages/triliumnext-bin"
}
}
},
macos: {
title: {
x64: t("download_helper_desktop_macos.title_x64"),
arm64: t("download_helper_desktop_macos.title_arm64")
},
description: {
x64: t("download_helper_desktop_macos.description_x64"),
arm64: t("download_helper_desktop_macos.description_arm64"),
},
quickStartTitle: t("download_helper_desktop_macos.quick_start"),
quickStartCode: "brew install --cask trilium-notes",
downloads: {
dmg: {
recommended: true,
name: t("download_helper_desktop_macos.download_dmg")
},
homebrew: {
name: t("download_helper_desktop_macos.download_homebrew_cask"),
url: "https://formulae.brew.sh/cask/trilium-notes#default"
},
zip: {
name: t("download_helper_desktop_macos.download_zip")
}
} }
} }
}, },
linux: { server: {
title: { docker: {
x64: t("download_helper_desktop_linux.title_x64"), title: t("download_helper_server_docker.title"),
arm64: t("download_helper_desktop_linux.title_arm64") description: t("download_helper_server_docker.description"),
}, helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.html",
description: { quickStartCode: "docker pull triliumnext/trilium\ndocker run -p 8080:8080 -d -v ./data:/home/node/trilium-data triliumnext/trilium",
x64: t("download_helper_desktop_linux.description_x64"), downloads: {
arm64: t("download_helper_desktop_linux.description_arm64"), dockerhub: {
}, name: t("download_helper_server_docker.download_dockerhub"),
quickStartTitle: t("download_helper_desktop_linux.quick_start"), url: "https://hub.docker.com/r/triliumnext/trilium"
downloads: { },
deb: { ghcr: {
recommended: true, name: t("download_helper_server_docker.download_ghcr"),
name: t("download_helper_desktop_linux.download_deb") url: "https://github.com/TriliumNext/Trilium/pkgs/container/trilium"
}, }
rpm: {
recommended: true,
name: t("download_helper_desktop_linux.download_rpm")
},
flatpak: {
name: t("download_helper_desktop_linux.download_flatpak")
},
zip: {
name: t("download_helper_desktop_linux.download_zip")
},
nixpkgs: {
name: t("download_helper_desktop_linux.download_nixpkgs"),
url: "https://search.nixos.org/packages?query=trilium-next"
},
aur: {
name: t("download_helper_desktop_linux.download_aur"),
url: "https://aur.archlinux.org/packages/triliumnext-bin"
} }
}
},
macos: {
title: {
x64: t("download_helper_desktop_macos.title_x64"),
arm64: t("download_helper_desktop_macos.title_arm64")
}, },
description: { linux: {
x64: t("download_helper_desktop_macos.description_x64"), title: t("download_helper_server_linux.title"),
arm64: t("download_helper_desktop_macos.description_arm64"), description: t("download_helper_server_linux.description"),
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Packaged%20version%20for%20Linux.html",
downloads: {
tarX64: {
recommended: true,
name: t("download_helper_server_linux.download_tar_x64"),
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-x64.tar.xz`,
},
tarArm64: {
recommended: true,
name: t("download_helper_server_linux.download_tar_arm64"),
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-arm64.tar.xz`
},
nixos: {
name: t("download_helper_server_linux.download_nixos"),
url: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/On%20NixOS"
}
}
}, },
quickStartTitle: t("download_helper_desktop_macos.quick_start"), pikapod: {
quickStartCode: "brew install --cask trilium-notes", title: t("download_helper_server_hosted.title"),
downloads: { description: t("download_helper_server_hosted.description"),
dmg: { downloads: {
recommended: true, pikapod: {
name: t("download_helper_desktop_macos.download_dmg") recommended: true,
}, name: t("download_helper_server_hosted.download_pikapod"),
homebrew: { url: "https://www.pikapods.com/pods?run=trilium-next"
name: t("download_helper_desktop_macos.download_homebrew_cask"), },
url: "https://formulae.brew.sh/cask/trilium-notes#default" triliumcc: {
}, name: t("download_helper_server_hosted.download_triliumcc"),
zip: { url: "https://trilium.cc/"
name: t("download_helper_desktop_macos.download_zip") }
}
}
}
},
server: {
docker: {
title: t("download_helper_server_docker.title"),
description: t("download_helper_server_docker.description"),
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Using%20Docker.html",
quickStartCode: "docker pull triliumnext/trilium\ndocker run -p 8080:8080 -d -v ./data:/home/node/trilium-data triliumnext/trilium",
downloads: {
dockerhub: {
name: t("download_helper_server_docker.download_dockerhub"),
url: "https://hub.docker.com/r/triliumnext/trilium"
},
ghcr: {
name: t("download_helper_server_docker.download_ghcr"),
url: "https://github.com/TriliumNext/Trilium/pkgs/container/trilium"
}
}
},
linux: {
title: t("download_helper_server_linux.title"),
description: t("download_helper_server_linux.description"),
helpUrl: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20%26%20Setup/Server%20Installation/1.%20Installing%20the%20server/Packaged%20version%20for%20Linux.html",
downloads: {
tarX64: {
recommended: true,
name: t("download_helper_server_linux.download_tar_x64"),
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-x64.tar.xz`,
},
tarArm64: {
recommended: true,
name: t("download_helper_server_linux.download_tar_arm64"),
url: `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-Server-v${version}-linux-arm64.tar.xz`
},
nixos: {
name: t("download_helper_server_linux.download_nixos"),
url: "https://docs.triliumnotes.org/User%20Guide/User%20Guide/Installation%20&%20Setup/Server%20Installation/1.%20Installing%20the%20server/On%20NixOS"
}
}
},
pikapod: {
title: t("download_helper_server_hosted.title"),
description: t("download_helper_server_hosted.description"),
downloads: {
pikapod: {
recommended: true,
name: t("download_helper_server_hosted.download_pikapod"),
url: "https://www.pikapods.com/pods?run=trilium-next"
},
triliumcc: {
name: t("download_helper_server_hosted.download_triliumcc"),
url: "https://trilium.cc/"
} }
} }
} }
} }
}; };
export function buildDownloadUrl(app: App, platform: Platform, format: string, architecture: Architecture): string { export function buildDownloadUrl(t: TFunction<"translation", undefined>, app: App, platform: Platform, format: string, architecture: Architecture): string {
const downloadMatrix = getDownloadMatrix(t);
if (app === "desktop") { if (app === "desktop") {
return downloadMatrix.desktop[platform]?.downloads[format].url ?? return downloadMatrix.desktop[platform]?.downloads[format].url ??
`https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-v${version}-${platform}-${architecture}.${format}`; `https://github.com/TriliumNext/Trilium/releases/download/v${version}/TriliumNotes-v${version}-${platform}-${architecture}.${format}`;
@ -218,8 +222,9 @@ export function getPlatform(): Platform | null {
} }
} }
export async function getRecommendedDownload(): Promise<RecommendedDownload | null> { export async function getRecommendedDownload(t: TFunction<"translation", undefined>): Promise<RecommendedDownload | null> {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const downloadMatrix = getDownloadMatrix(t);
const architecture = await getArchitecture(); const architecture = await getArchitecture();
const platform = getPlatform(); const platform = getPlatform();
@ -233,7 +238,7 @@ export async function getRecommendedDownload(): Promise<RecommendedDownload | nu
if (!recommendedDownload) return null; if (!recommendedDownload) return null;
const format = recommendedDownload[0]; const format = recommendedDownload[0];
const url = buildDownloadUrl("desktop", platform, format || 'zip', architecture); const url = buildDownloadUrl(t, "desktop", platform, format || 'zip', architecture);
const platformTitle = platformInfo.title; const platformTitle = platformInfo.title;
const name = typeof platformTitle === "string" ? platformTitle : platformTitle[architecture] as string; const name = typeof platformTitle === "string" ? platformTitle : platformTitle[architecture] as string;

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { extractLocaleFromUrl, mapLocale, swapLocaleInUrl } from "./i18n";
describe("mapLocale", () => {
it("maps Chinese", () => {
expect(mapLocale("zh-TW")).toStrictEqual("zh-Hant");
expect(mapLocale("zh-CN")).toStrictEqual("zh-Hans");
});
it("maps languages without countries", () => {
expect(mapLocale("ro-RO")).toStrictEqual("ro");
expect(mapLocale("ro")).toStrictEqual("ro");
});
});
describe("swapLocale", () => {
it("swap locale in URL", () => {
expect(swapLocaleInUrl("/get-started", "ro")).toStrictEqual("/ro/get-started");
expect(swapLocaleInUrl("/ro/get-started", "ro")).toStrictEqual("/ro/get-started");
expect(swapLocaleInUrl("/en/get-started", "ro")).toStrictEqual("/ro/get-started");
expect(swapLocaleInUrl("/ro/", "en")).toStrictEqual("/en/");
});
});
describe("extractLocaleFromUrl", () => {
it("properly extracts locale", () => {
expect(extractLocaleFromUrl("/en/get-started")).toStrictEqual("en");
expect(extractLocaleFromUrl("/get-started")).toStrictEqual(undefined);
expect(extractLocaleFromUrl("/")).toStrictEqual(undefined);
});
});

View File

@ -1,19 +1,50 @@
import { default as i18next } from "i18next"; interface Locale {
import HttpApi from 'i18next-http-backend'; id: string;
import { initReactI18next } from "react-i18next"; name: string;
rtl?: boolean;
}
i18next export const LOCALES: Locale[] = [
.use(HttpApi) { id: "en", name: "English" },
.use(initReactI18next); { id: "ro", name: "Română" },
{ id: "zh-Hans", name: "简体中文" },
{ id: "zh-Hant", name: "繁體中文" },
{ id: "fr", name: "Français" },
{ id: "it", name: "Italiano" },
{ id: "ja", name: "日本語" },
{ id: "pl", name: "Polski" },
{ id: "es", name: "Español" },
{ id: "ar", name: "اَلْعَرَبِيَّةُ", rtl: true },
].toSorted((a, b) => a.name.localeCompare(b.name));
await i18next.init({ export function mapLocale(locale: string) {
debug: true, if (!locale) return 'en';
lng: "en", const lower = locale.toLowerCase();
fallbackLng: "en",
backend: {
loadPath: "/translations/{{lng}}/{{ns}}.json",
},
returnEmptyString: false
});
export const t = i18next.t; if (lower.startsWith('zh')) {
if (lower.includes('tw') || lower.includes('hk') || lower.includes('mo') || lower.includes('hant')) {
return 'zh-Hant';
}
return 'zh-Hans';
}
// Default for everything else
return locale.split('-')[0]; // e.g. "en-US" -> "en"
}
export function swapLocaleInUrl(url: string, newLocale: string) {
const components = url.split("/");
if (components.length === 2) {
return `/${newLocale}${url}`;
} else {
components[1] = newLocale;
return components.join("/");
}
}
export function extractLocaleFromUrl(url: string) {
const localeId = url.split('/')[1];
const correspondingLocale = LOCALES.find(l => l.id === localeId);
if (!correspondingLocale) return undefined;
return localeId;
}

View File

@ -2,29 +2,78 @@ import './style.css';
import { FALLBACK_STARGAZERS_COUNT, getRepoStargazersCount } from './github-utils.js'; import { FALLBACK_STARGAZERS_COUNT, getRepoStargazersCount } from './github-utils.js';
import { Header } from './components/Header.jsx'; import { Header } from './components/Header.jsx';
import { Home } from './pages/Home/index.jsx'; import { Home } from './pages/Home/index.jsx';
import { LocationProvider, Router, Route, hydrate, prerender as ssr } from 'preact-iso'; import { LocationProvider, Router, Route, hydrate, prerender as ssr, useLocation } from 'preact-iso';
import { NotFound } from './pages/_404.jsx'; import { NotFound } from './pages/_404.jsx';
import Footer from './components/Footer.js'; import Footer from './components/Footer.js';
import GetStarted from './pages/GetStarted/get-started.js'; import GetStarted from './pages/GetStarted/get-started.js';
import SupportUs from './pages/SupportUs/SupportUs.js'; import SupportUs from './pages/SupportUs/SupportUs.js';
import { createContext } from 'preact';
import { useLayoutEffect, useState } from 'preact/hooks';
import { default as i18next, changeLanguage } from 'i18next';
import { extractLocaleFromUrl, LOCALES, mapLocale } from './i18n';
import HttpApi from 'i18next-http-backend';
import { initReactI18next } from "react-i18next";
export const LocaleContext = createContext('en');
export function App(props: {repoStargazersCount: number}) { export function App(props: {repoStargazersCount: number}) {
return ( return (
<LocationProvider> <LocationProvider>
<Header repoStargazersCount={props.repoStargazersCount} /> <LocaleProvider>
<main> <Header repoStargazersCount={props.repoStargazersCount} />
<Router> <main>
<Route path="/" component={Home} /> <Router>
<Route default component={NotFound} /> <Route path="/" component={Home} />
<Route path="/get-started" component={GetStarted} /> <Route path="/get-started" component={GetStarted} />
<Route path="/support-us" component={SupportUs} /> <Route path="/support-us" component={SupportUs} />
</Router>
</main> <Route path="/:locale:/" component={Home} />
<Footer /> <Route path="/:locale:/get-started" component={GetStarted} />
<Route path="/:locale:/support-us" component={SupportUs} />
<Route default component={NotFound} />
</Router>
</main>
<Footer />
</LocaleProvider>
</LocationProvider> </LocationProvider>
); );
} }
export function LocaleProvider({ children }) {
const { path } = useLocation();
const localeId = mapLocale(extractLocaleFromUrl(path) || navigator.language);
const [ loaded, setLoaded ] = useState(false);
useLayoutEffect(() => {
i18next
.use(HttpApi)
.use(initReactI18next);
i18next.init({
lng: localeId,
fallbackLng: "en",
backend: {
loadPath: "/translations/{{lng}}/{{ns}}.json",
},
returnEmptyString: false
}).then(() => setLoaded(true))
}, []);
useLayoutEffect(() => {
if (!loaded) return;
changeLanguage(localeId);
const correspondingLocale = LOCALES.find(l => l.id === localeId);
document.documentElement.lang = localeId;
document.documentElement.dir = correspondingLocale?.rtl ? "rtl" : "ltr";
}, [ loaded, localeId ]);
return (
<LocaleContext.Provider value={localeId}>
{loaded && children}
</LocaleContext.Provider>
);
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!); hydrate(<App repoStargazersCount={FALLBACK_STARGAZERS_COUNT} />, document.getElementById('app')!);
} }

View File

@ -1,18 +1,20 @@
import { useLayoutEffect, useState } from "preact/hooks"; import { useLayoutEffect, useState } from "preact/hooks";
import Card from "../../components/Card.js"; import Card from "../../components/Card.js";
import Section from "../../components/Section.js"; import Section from "../../components/Section.js";
import { App, Architecture, buildDownloadUrl, downloadMatrix, DownloadMatrixEntry, getArchitecture, getPlatform, Platform } from "../../download-helper.js"; import { App, Architecture, buildDownloadUrl, DownloadMatrixEntry, getArchitecture, getDownloadMatrix, getPlatform, Platform } from "../../download-helper.js";
import { usePageTitle } from "../../hooks.js"; import { usePageTitle } from "../../hooks.js";
import Button, { Link } from "../../components/Button.js"; import Button, { Link } from "../../components/Button.js";
import Icon from "../../components/Icon.js"; import Icon from "../../components/Icon.js";
import helpIcon from "../../assets/boxicons/bx-help-circle.svg?raw"; import helpIcon from "../../assets/boxicons/bx-help-circle.svg?raw";
import "./get-started.css"; import "./get-started.css";
import packageJson from "../../../../../package.json" with { type: "json" }; import packageJson from "../../../../../package.json" with { type: "json" };
import { t } from "../../i18n.js"; import { useTranslation } from "react-i18next";
export default function DownloadPage() { export default function DownloadPage() {
const { t } = useTranslation();
const [ currentArch, setCurrentArch ] = useState<Architecture>("x64"); const [ currentArch, setCurrentArch ] = useState<Architecture>("x64");
const [ userPlatform, setUserPlatform ] = useState<Platform>(); const [ userPlatform, setUserPlatform ] = useState<Platform>();
const downloadMatrix = getDownloadMatrix(t);
useLayoutEffect(() => { useLayoutEffect(() => {
getArchitecture().then((arch) => setCurrentArch(arch ?? "x64")); getArchitecture().then((arch) => setCurrentArch(arch ?? "x64"));
@ -71,6 +73,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
return (typeof text === "string" ? text : text[arch]); return (typeof text === "string" ? text : text[arch]);
} }
const { t } = useTranslation();
const allDownloads = Object.entries(entry.downloads); const allDownloads = Object.entries(entry.downloads);
const recommendedDownloads = allDownloads.filter(download => download[1].recommended); const recommendedDownloads = allDownloads.filter(download => download[1].recommended);
const restDownloads = allDownloads.filter(download => !download[1].recommended); const restDownloads = allDownloads.filter(download => !download[1].recommended);
@ -107,7 +110,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
{recommendedDownloads.map(recommendedDownload => ( {recommendedDownloads.map(recommendedDownload => (
<Button <Button
className="recommended" className="recommended"
href={buildDownloadUrl(app, platform as Platform, recommendedDownload[0], arch)} href={buildDownloadUrl(t, app, platform as Platform, recommendedDownload[0], arch)}
text={recommendedDownload[1].name} text={recommendedDownload[1].name}
openExternally={!!recommendedDownload[1].url} openExternally={!!recommendedDownload[1].url}
/> />
@ -117,7 +120,7 @@ export function DownloadCard({ app, arch, entry: [ platform, entry ], isRecommen
<div class="other-options"> <div class="other-options">
{restDownloads.map(download => ( {restDownloads.map(download => (
<Link <Link
href={buildDownloadUrl(app, platform as Platform, download[0], arch)} href={buildDownloadUrl(t, app, platform as Platform, download[0], arch)}
openExternally={!!download[1].url} openExternally={!!download[1].url}
> >
{download[1].name} {download[1].name}

View File

@ -57,6 +57,8 @@ section.hero-section {
color: transparent; color: transparent;
line-height: 1.1; line-height: 1.1;
font-weight: 400; font-weight: 400;
font-size: 2em;
margin-block: 0.65em;
} }
} }

View File

@ -31,8 +31,7 @@ import boardIcon from "../../assets/boxicons/bx-columns-3.svg?raw";
import geomapIcon from "../../assets/boxicons/bx-map.svg?raw"; import geomapIcon from "../../assets/boxicons/bx-map.svg?raw";
import { getPlatform } from '../../download-helper.js'; import { getPlatform } from '../../download-helper.js';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { t } from '../../i18n.js'; import { Trans, useTranslation } from 'react-i18next';
import { Trans } from 'react-i18next';
export function Home() { export function Home() {
usePageTitle(""); usePageTitle("");
@ -52,6 +51,7 @@ export function Home() {
} }
function HeroSection() { function HeroSection() {
const { t } = useTranslation();
const platform = getPlatform(); const platform = getPlatform();
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const [ screenshotUrl, setScreenshotUrl ] = useState<string>(); const [ screenshotUrl, setScreenshotUrl ] = useState<string>();
@ -96,6 +96,7 @@ function HeroSection() {
} }
function OrganizationBenefitsSection() { function OrganizationBenefitsSection() {
const { t } = useTranslation();
return ( return (
<> <>
<Section className="benefits" title={t("organization_benefits.title")}> <Section className="benefits" title={t("organization_benefits.title")}>
@ -110,6 +111,7 @@ function OrganizationBenefitsSection() {
} }
function ProductivityBenefitsSection() { function ProductivityBenefitsSection() {
const { t } = useTranslation();
return ( return (
<> <>
<Section className="benefits accented" title={t("productivity_benefits.title")}> <Section className="benefits accented" title={t("productivity_benefits.title")}>
@ -127,8 +129,9 @@ function ProductivityBenefitsSection() {
} }
function NoteTypesSection() { function NoteTypesSection() {
const { t } = useTranslation();
return ( return (
<Section className="note-types" title="Multiple ways to represent your information"> <Section className="note-types" title={t("note_types.title")}>
<ListWithScreenshot horizontal items={[ <ListWithScreenshot horizontal items={[
{ {
title: t("note_types.text_title"), title: t("note_types.text_title"),
@ -190,6 +193,7 @@ function NoteTypesSection() {
} }
function ExtensibilityBenefitsSection() { function ExtensibilityBenefitsSection() {
const { t } = useTranslation();
return ( return (
<> <>
<Section className="benefits accented" title={t("extensibility_benefits.title")}> <Section className="benefits accented" title={t("extensibility_benefits.title")}>
@ -205,8 +209,9 @@ function ExtensibilityBenefitsSection() {
} }
function CollectionsSection() { function CollectionsSection() {
const { t } = useTranslation();
return ( return (
<Section className="collections" title="Collections"> <Section className="collections" title={t("collections.title")}>
<ListWithScreenshot items={[ <ListWithScreenshot items={[
{ {
title: t("collections.calendar_title"), title: t("collections.calendar_title"),
@ -247,6 +252,7 @@ function ListWithScreenshot({ items, horizontal, cardExtra }: {
cardExtra?: ComponentChildren; cardExtra?: ComponentChildren;
}) { }) {
const [ selectedItem, setSelectedItem ] = useState(items[0]); const [ selectedItem, setSelectedItem ] = useState(items[0]);
const { t } = useTranslation();
return ( return (
<div className={`list-with-screenshot ${horizontal ? "horizontal" : ""}`}> <div className={`list-with-screenshot ${horizontal ? "horizontal" : ""}`}>
@ -278,6 +284,7 @@ function ListWithScreenshot({ items, horizontal, cardExtra }: {
} }
function FaqSection() { function FaqSection() {
const { t } = useTranslation();
return ( return (
<Section className="faq" title={t("faq.title")}> <Section className="faq" title={t("faq.title")}>
<div class="grid-2-cols"> <div class="grid-2-cols">
@ -301,6 +308,7 @@ function FaqItem({ question, children }: { question: string; children: Component
} }
function FinalCta() { function FinalCta() {
const { t } = useTranslation();
return ( return (
<Section className="final-cta accented" title={t("final_cta.title")}> <Section className="final-cta accented" title={t("final_cta.title")}>
<p>{t("final_cta.description")}</p> <p>{t("final_cta.description")}</p>

View File

@ -6,10 +6,10 @@ import buyMeACoffeeIcon from "../../assets/boxicons/bx-buy-me-a-coffee.svg?raw";
import Button, { Link } from "../../components/Button.js"; import Button, { Link } from "../../components/Button.js";
import Card from "../../components/Card.js"; import Card from "../../components/Card.js";
import { usePageTitle } from "../../hooks.js"; import { usePageTitle } from "../../hooks.js";
import { t } from "../../i18n.js"; import { Trans, useTranslation } from "react-i18next";
import { Trans } from "react-i18next";
export default function Donate() { export default function Donate() {
const { t } = useTranslation();
usePageTitle(t("support_us.title")); usePageTitle(t("support_us.title"));
return ( return (

View File

@ -1,9 +1,10 @@
import { useTranslation } from "react-i18next";
import Section from "../components/Section.js"; import Section from "../components/Section.js";
import { usePageTitle } from "../hooks.js"; import { usePageTitle } from "../hooks.js";
import { t } from "../i18n.js";
import "./_404.css"; import "./_404.css";
export function NotFound() { export function NotFound() {
const { t } = useTranslation();
usePageTitle(t("404.title")); usePageTitle(t("404.title"));
return ( return (

View File

@ -31,7 +31,13 @@ html,
body { body {
margin: 0; margin: 0;
line-height: 1.5; line-height: 1.5;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; font-family: Inter,
system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Noto Sans CJK SC",
"Hiragino Sans", "Hiragino Kaku Gothic ProN",
"Microsoft YaHei", "Meiryo", "Malgun Gothic",
"PingFang SC", "Source Han Sans SC",
"Source Han Sans JP", "Source Han Sans KR";
min-height: 100vh; min-height: 100vh;
} }

View File

@ -14,4 +14,7 @@ export default defineConfig({
}, },
}), }),
], ],
test: {
environment: "happy-dom"
}
}); });

View File

@ -17,6 +17,7 @@
"desktop:start": "pnpm run --filter desktop dev", "desktop:start": "pnpm run --filter desktop dev",
"desktop:build": "pnpm run --filter desktop build", "desktop:build": "pnpm run --filter desktop build",
"desktop:start-prod": "pnpm run --filter desktop start-prod", "desktop:start-prod": "pnpm run --filter desktop start-prod",
"website:start": "pnpm run --filter website dev",
"website:build": "pnpm run --filter website build", "website:build": "pnpm run --filter website build",
"electron:build": "pnpm desktop:build", "electron:build": "pnpm desktop:build",
"electron:start": "pnpm desktop:start", "electron:start": "pnpm desktop:start",