mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(docs): localize the docs homepage, nav, and sidebar chrome across 20 languages (#547)
Localize the docs homepage (DocsHome.vue), top nav, and sidebar structural labels into all 20 non-English locales; individual tool names stay English by design. Also derive the homepage tool-count chips from the shared catalog via toolSection() so they no longer drift.
This commit is contained in:
@@ -3,6 +3,7 @@ import llmstxt from "vitepress-plugin-llms";
|
||||
import { pagefindPlugin } from "vitepress-plugin-pagefind";
|
||||
import pkg from "../../../package.json";
|
||||
import { SUPPORTED_LOCALES } from "../../../packages/shared/src/i18n/index.ts";
|
||||
import { t } from "./i18n/ui.mjs";
|
||||
|
||||
const NON_EN = SUPPORTED_LOCALES.filter((l) => l.code !== "en");
|
||||
const HOSTNAME = "https://docs.snapotter.com";
|
||||
@@ -20,6 +21,93 @@ function prefixLinks(items: any[], locale: string): any[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Translate top-level nav labels for a locale via the shared UI catalog. Labels
|
||||
// with no mapping (e.g. the version item) pass through unchanged. The nav is a
|
||||
// flat list, so no recursion is needed here.
|
||||
const NAV_KEY: Record<string, string> = {
|
||||
Home: "nav.home",
|
||||
Guide: "nav.guide",
|
||||
Tools: "nav.tools",
|
||||
"API Reference": "nav.apiReference",
|
||||
Changelog: "nav.changelog",
|
||||
};
|
||||
// biome-ignore lint/suspicious/noExplicitAny: VitePress nav item trees are recursively typed.
|
||||
function localizeNav(items: any[], locale: string): any[] {
|
||||
return (items ?? []).map((it) => {
|
||||
const next = { ...it };
|
||||
const key = NAV_KEY[next.text];
|
||||
if (key) next.text = t(locale, key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Structural sidebar labels -> UI-catalog key. Individual tool names (the
|
||||
// /tools/ leaves) are intentionally absent, so they stay in English; the
|
||||
// acronym-only guide labels (OIDC / SSO, SAML SSO) are absent for the same
|
||||
// reason. Reuses nav.* / home.mod.* / home.card.* keys where the label matches.
|
||||
const SIDEBAR_KEY: Record<string, string> = {
|
||||
Guide: "nav.guide",
|
||||
Tools: "nav.tools",
|
||||
Image: "home.mod.image",
|
||||
Video: "home.mod.video",
|
||||
Audio: "home.mod.audio",
|
||||
PDF: "home.mod.pdf",
|
||||
Files: "home.mod.files",
|
||||
Essentials: "sidebar.cat.essentials",
|
||||
Optimization: "sidebar.cat.optimization",
|
||||
Adjustments: "sidebar.cat.adjustments",
|
||||
"Watermark & Overlay": "sidebar.cat.watermarkOverlay",
|
||||
Utilities: "sidebar.cat.utilities",
|
||||
Layout: "sidebar.cat.layout",
|
||||
Format: "sidebar.cat.format",
|
||||
"AI Tools": "sidebar.cat.aiTools",
|
||||
"API reference": "sidebar.sec.apiReference",
|
||||
Project: "sidebar.sec.project",
|
||||
"REST API": "home.card.restApi",
|
||||
"Image engine": "sidebar.page.imageEngine",
|
||||
"AI engine": "sidebar.page.aiEngine",
|
||||
Changelog: "nav.changelog",
|
||||
"Getting started": "sidebar.guide.gettingStarted",
|
||||
Architecture: "sidebar.guide.architecture",
|
||||
Configuration: "sidebar.guide.configuration",
|
||||
"SCIM Provisioning": "sidebar.guide.scimProvisioning",
|
||||
"Users, Roles & Permissions": "sidebar.guide.usersRolesPermissions",
|
||||
Database: "sidebar.guide.database",
|
||||
"Upgrading from 1.x": "sidebar.guide.upgrading",
|
||||
Deployment: "sidebar.guide.deployment",
|
||||
"Security & Hardening": "sidebar.guide.securityHardening",
|
||||
"What SnapOtter collects": "sidebar.guide.telemetry",
|
||||
"Supported Formats": "sidebar.guide.supportedFormats",
|
||||
"Hardware requirements": "sidebar.guide.hardware",
|
||||
"Docker tags": "sidebar.guide.dockerTags",
|
||||
"Developer guide": "sidebar.guide.developer",
|
||||
"Translation guide": "sidebar.guide.translations",
|
||||
Contributing: "sidebar.guide.contributing",
|
||||
};
|
||||
|
||||
// Translate a locale's sidebar tree. Only "structural" nodes are eligible:
|
||||
// group headers (anything with child items) and non-tool doc pages (/guide/,
|
||||
// /api/, /changelog). Tool leaves (/tools/) never qualify, so a tool whose name
|
||||
// happens to collide with a structural label is still left in English.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: VitePress sidebar item trees are recursively typed.
|
||||
function localizeSidebar(items: any[], locale: string): any[] {
|
||||
return (items ?? []).map((it) => {
|
||||
const next = { ...it };
|
||||
const link = typeof next.link === "string" ? next.link : "";
|
||||
const structural =
|
||||
Array.isArray(next.items) ||
|
||||
link.startsWith("/guide/") ||
|
||||
link.startsWith("/api/") ||
|
||||
link === "/changelog";
|
||||
if (structural) {
|
||||
const key = SIDEBAR_KEY[next.text];
|
||||
if (key) next.text = t(locale, key);
|
||||
}
|
||||
if (Array.isArray(next.items)) next.items = localizeSidebar(next.items, locale);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
title: "SnapOtter",
|
||||
description:
|
||||
@@ -177,11 +265,11 @@ export default defineConfig({
|
||||
dir: l.dir,
|
||||
link: `/${l.code}/`,
|
||||
themeConfig: {
|
||||
nav: prefixLinks(buildBaseTheme().nav, l.code),
|
||||
sidebar: prefixLinks(buildBaseTheme().sidebar, l.code),
|
||||
nav: prefixLinks(localizeNav(buildBaseTheme().nav, l.code), l.code),
|
||||
sidebar: prefixLinks(localizeSidebar(buildBaseTheme().sidebar, l.code), l.code),
|
||||
editLink: {
|
||||
pattern: "https://github.com/snapotter-hq/snapotter/edit/main/apps/docs/:path",
|
||||
text: "Edit this page on GitHub",
|
||||
text: t(l.code, "sidebar.editLink"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,95 @@
|
||||
// apps/docs/.vitepress/i18n/ui.mjs
|
||||
import { SUPPORTED_LOCALES } from "../../../../packages/shared/src/i18n/index.ts";
|
||||
import { OVERRIDES } from "./overrides.mjs";
|
||||
|
||||
// Source strings (English). Other locales override keys as translations land;
|
||||
// missing keys fall back to English via t().
|
||||
// Source strings (English). Other locales override keys via ./overrides.mjs;
|
||||
// missing keys fall back to English through t().
|
||||
const EN = {
|
||||
// DocsHome.vue
|
||||
// DocsHome.vue - hero
|
||||
"home.title": "SnapOtter Documentation",
|
||||
"home.heroSub":
|
||||
"Install, operate, and build on your self-hosted file-processing infrastructure. Get running in one command:",
|
||||
"home.copy": "Copy",
|
||||
"home.copied": "Copied!",
|
||||
"home.clickToCopy": "Click to copy",
|
||||
"home.copyCommandAria": "Copy command",
|
||||
"home.fullInstallGuide": "Full install guide",
|
||||
"home.gpuComposeSetup": "GPU & Compose setup",
|
||||
"home.tryDemo": "Try the live demo",
|
||||
// DocsHome.vue - doors
|
||||
"home.selfHosting": "Self-hosting",
|
||||
"home.selfHostingSub": "Get SnapOtter running and keep it healthy.",
|
||||
"home.enterprise": "Enterprise",
|
||||
"home.enterpriseSub": "Evaluate, secure & govern your deployment.",
|
||||
"home.startSelfHosting": "Start self-hosting →",
|
||||
"home.evaluate": "See enterprise features →",
|
||||
// DocsHome.vue - self-hosting links
|
||||
"home.link.quickStart": "Quick start",
|
||||
"home.link.configuration": "Configuration",
|
||||
"home.link.hardwareSizing": "Hardware & sizing",
|
||||
"home.link.databaseBackups": "Database & backups",
|
||||
"home.link.dockerTagsGpu": "Docker tags & GPU",
|
||||
"home.link.supportedFormats": "Supported formats",
|
||||
// DocsHome.vue - enterprise links
|
||||
"home.link.architecture": "Architecture",
|
||||
"home.link.securityHardening": "Security & hardening",
|
||||
"home.link.ssoSamlOidc": "SSO · SAML · OIDC",
|
||||
"home.link.scimProvisioning": "SCIM provisioning",
|
||||
"home.link.usersRolesAudit": "Users, roles & audit",
|
||||
"home.link.complianceSbom": "Compliance & SBOM",
|
||||
// DocsHome.vue - modalities
|
||||
"home.modalities": "200+ tools across 5 modalities",
|
||||
"home.browseByType": "browse the full reference by type",
|
||||
"home.mod.image": "Image",
|
||||
"home.mod.video": "Video",
|
||||
"home.mod.audio": "Audio",
|
||||
"home.mod.pdf": "PDF",
|
||||
"home.mod.files": "Files",
|
||||
"home.toolsSuffix": "tools",
|
||||
// DocsHome.vue - shared cards
|
||||
"home.card.restApi": "REST API",
|
||||
"home.card.restApiSub": "Keys, endpoints & OpenAPI",
|
||||
"home.card.changelog": "Changelog",
|
||||
"home.card.changelogSub": "What's new in 2.0",
|
||||
"home.card.llmsTxt": "llms.txt",
|
||||
"home.card.llmsTxtSub": "AI-friendly docs",
|
||||
// Top nav (config.mts)
|
||||
"nav.home": "Home",
|
||||
"nav.guide": "Guide",
|
||||
"nav.tools": "Tools",
|
||||
"nav.apiReference": "API Reference",
|
||||
"nav.changelog": "Changelog",
|
||||
// Sidebar structural labels (config.mts). Individual tool names stay English;
|
||||
// the acronym-only guide labels (OIDC / SSO, SAML SSO) are left untranslated.
|
||||
"sidebar.cat.essentials": "Essentials",
|
||||
"sidebar.cat.optimization": "Optimization",
|
||||
"sidebar.cat.adjustments": "Adjustments",
|
||||
"sidebar.cat.watermarkOverlay": "Watermark & Overlay",
|
||||
"sidebar.cat.utilities": "Utilities",
|
||||
"sidebar.cat.layout": "Layout",
|
||||
"sidebar.cat.format": "Format",
|
||||
"sidebar.cat.aiTools": "AI Tools",
|
||||
"sidebar.sec.apiReference": "API reference",
|
||||
"sidebar.sec.project": "Project",
|
||||
"sidebar.page.imageEngine": "Image engine",
|
||||
"sidebar.page.aiEngine": "AI engine",
|
||||
"sidebar.guide.gettingStarted": "Getting started",
|
||||
"sidebar.guide.architecture": "Architecture",
|
||||
"sidebar.guide.configuration": "Configuration",
|
||||
"sidebar.guide.scimProvisioning": "SCIM Provisioning",
|
||||
"sidebar.guide.usersRolesPermissions": "Users, Roles & Permissions",
|
||||
"sidebar.guide.database": "Database",
|
||||
"sidebar.guide.upgrading": "Upgrading from 1.x",
|
||||
"sidebar.guide.deployment": "Deployment",
|
||||
"sidebar.guide.securityHardening": "Security & Hardening",
|
||||
"sidebar.guide.telemetry": "What SnapOtter collects",
|
||||
"sidebar.guide.supportedFormats": "Supported Formats",
|
||||
"sidebar.guide.hardware": "Hardware requirements",
|
||||
"sidebar.guide.dockerTags": "Docker tags",
|
||||
"sidebar.guide.developer": "Developer guide",
|
||||
"sidebar.guide.translations": "Translation guide",
|
||||
"sidebar.guide.contributing": "Contributing",
|
||||
"sidebar.editLink": "Edit this page on GitHub",
|
||||
// FundButton.vue
|
||||
"fund.label": "Fund Development",
|
||||
// GitHubStars.vue
|
||||
@@ -27,11 +103,6 @@ const EN = {
|
||||
"notFound.link": "Back to docs",
|
||||
};
|
||||
|
||||
// Per-locale overrides. Generated/refined by the pipeline in Plan 05; ships with
|
||||
// English only, so t() returns English for every non-en locale until then.
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
const OVERRIDES = {};
|
||||
|
||||
/**
|
||||
* @param {string} locale
|
||||
* @param {string} key
|
||||
@@ -52,4 +123,4 @@ export function normalizeLocale(lang) {
|
||||
return SUPPORTED_LOCALES.some((l) => l.code === code) ? code : "en";
|
||||
}
|
||||
|
||||
export { SUPPORTED_LOCALES };
|
||||
export { EN, SUPPORTED_LOCALES };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useData } from "vitepress";
|
||||
import { computed, ref } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
import { data as sectionCounts } from "./section-counts.data.ts";
|
||||
|
||||
const { lang } = useData();
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
@@ -11,32 +12,32 @@ const command =
|
||||
"docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest";
|
||||
|
||||
const selfLinks = [
|
||||
{ label: "Quick start", href: "/guide/getting-started#quick-start" },
|
||||
{ label: "Configuration", href: "/guide/configuration" },
|
||||
{ label: "Hardware & sizing", href: "/guide/deployment#hardware-requirements" },
|
||||
{ label: "Database & backups", href: "/guide/database" },
|
||||
{ label: "Docker tags & GPU", href: "/guide/docker-tags" },
|
||||
{ label: "Supported formats", href: "/guide/supported-formats" },
|
||||
{ key: "home.link.quickStart", href: "/guide/getting-started#quick-start" },
|
||||
{ key: "home.link.configuration", href: "/guide/configuration" },
|
||||
{ key: "home.link.hardwareSizing", href: "/guide/deployment#hardware-requirements" },
|
||||
{ key: "home.link.databaseBackups", href: "/guide/database" },
|
||||
{ key: "home.link.dockerTagsGpu", href: "/guide/docker-tags" },
|
||||
{ key: "home.link.supportedFormats", href: "/guide/supported-formats" },
|
||||
];
|
||||
const entLinks = [
|
||||
{ label: "Architecture", href: "/guide/architecture" },
|
||||
{ label: "Security & hardening", href: "/guide/security" },
|
||||
{ label: "SSO · SAML · OIDC", href: "/guide/oidc" },
|
||||
{ label: "SCIM provisioning", href: "/guide/scim" },
|
||||
{ label: "Users, roles & audit", href: "/guide/users-roles" },
|
||||
{ label: "Compliance & SBOM", href: "/guide/security#compliance-artifacts" },
|
||||
{ key: "home.link.architecture", href: "/guide/architecture" },
|
||||
{ key: "home.link.securityHardening", href: "/guide/security" },
|
||||
{ key: "home.link.ssoSamlOidc", href: "/guide/oidc" },
|
||||
{ key: "home.link.scimProvisioning", href: "/guide/scim" },
|
||||
{ key: "home.link.usersRolesAudit", href: "/guide/users-roles" },
|
||||
{ key: "home.link.complianceSbom", href: "/guide/security#compliance-artifacts" },
|
||||
];
|
||||
const modalities = [
|
||||
{ label: "Image", count: 64, href: "/tools/image/resize" },
|
||||
{ label: "Video", count: 29, href: "/tools/video/convert-video" },
|
||||
{ label: "Audio", count: 17, href: "/tools/audio/convert-audio" },
|
||||
{ label: "PDF", count: 37, href: "/tools/pdf/merge-pdf" },
|
||||
{ label: "Files", count: 10, href: "/tools/files/chart-maker" },
|
||||
{ key: "home.mod.image", count: sectionCounts.image, href: "/tools/image/resize" },
|
||||
{ key: "home.mod.video", count: sectionCounts.video, href: "/tools/video/convert-video" },
|
||||
{ key: "home.mod.audio", count: sectionCounts.audio, href: "/tools/audio/convert-audio" },
|
||||
{ key: "home.mod.pdf", count: sectionCounts.pdf, href: "/tools/pdf/merge-pdf" },
|
||||
{ key: "home.mod.files", count: sectionCounts.files, href: "/tools/files/chart-maker" },
|
||||
];
|
||||
const shared = [
|
||||
{ label: "REST API", sub: "Keys, endpoints & OpenAPI", href: "/api/rest" },
|
||||
{ label: "Changelog", sub: "What's new in 2.0", href: "/changelog" },
|
||||
{ label: "llms.txt", sub: "AI-friendly docs", href: "/llms.txt" },
|
||||
{ key: "home.card.restApi", subKey: "home.card.restApiSub", href: "/api/rest" },
|
||||
{ key: "home.card.changelog", subKey: "home.card.changelogSub", href: "/changelog" },
|
||||
{ key: "home.card.llmsTxt", subKey: "home.card.llmsTxtSub", href: "/llms.txt" },
|
||||
];
|
||||
|
||||
const copyLabel = ref(t(locale.value, "home.copy"));
|
||||
@@ -53,18 +54,15 @@ function copyCommand() {
|
||||
<div class="so-home">
|
||||
<section class="hero">
|
||||
<h1 class="hero-title">{{ tt("home.title") }}</h1>
|
||||
<p class="hero-sub">
|
||||
Install, operate, and build on your self-hosted file-processing infrastructure. Get running
|
||||
in one command:
|
||||
</p>
|
||||
<div class="cmd" title="Click to copy" @click="copyCommand">
|
||||
<p class="hero-sub">{{ tt("home.heroSub") }}</p>
|
||||
<div class="cmd" :title="tt('home.clickToCopy')" @click="copyCommand">
|
||||
<code>$ {{ command }}</code>
|
||||
<button class="copy" type="button" aria-label="Copy command" @click.stop="copyCommand">{{ copyLabel }}</button>
|
||||
<button class="copy" type="button" :aria-label="tt('home.copyCommandAria')" @click.stop="copyCommand">{{ copyLabel }}</button>
|
||||
</div>
|
||||
<p class="hero-meta">
|
||||
<a href="/guide/getting-started">Full install guide</a> ·
|
||||
<a href="/guide/getting-started#docker-compose">GPU & Compose setup</a> ·
|
||||
<a href="https://demo.snapotter.com">Try the live demo</a>
|
||||
<a href="/guide/getting-started">{{ tt("home.fullInstallGuide") }}</a> ·
|
||||
<a href="/guide/getting-started#docker-compose">{{ tt("home.gpuComposeSetup") }}</a> ·
|
||||
<a href="https://demo.snapotter.com">{{ tt("home.tryDemo") }}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -76,9 +74,9 @@ function copyCommand() {
|
||||
</span>
|
||||
<h2>{{ tt("home.selfHosting") }}</h2>
|
||||
</div>
|
||||
<p class="door-sub">Get SnapOtter running and keep it healthy.</p>
|
||||
<p class="door-sub">{{ tt("home.selfHostingSub") }}</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in selfLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
<li v-for="l in selfLinks" :key="l.href"><a :href="l.href">{{ tt(l.key) }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="/guide/getting-started">{{ tt("home.startSelfHosting") }}</a>
|
||||
</div>
|
||||
@@ -90,9 +88,9 @@ function copyCommand() {
|
||||
</span>
|
||||
<h2>{{ tt("home.enterprise") }}</h2>
|
||||
</div>
|
||||
<p class="door-sub">Evaluate, secure & govern your deployment.</p>
|
||||
<p class="door-sub">{{ tt("home.enterpriseSub") }}</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in entLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
<li v-for="l in entLinks" :key="l.href"><a :href="l.href">{{ tt(l.key) }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="https://snapotter.com/enterprise">{{ tt("home.evaluate") }}</a>
|
||||
</div>
|
||||
@@ -102,16 +100,16 @@ function copyCommand() {
|
||||
<p class="mod-head"><strong>{{ tt("home.modalities") }}</strong> <span>{{ tt("home.browseByType") }}</span></p>
|
||||
<div class="chips">
|
||||
<a v-for="m in modalities" :key="m.href" class="chip" :href="m.href">
|
||||
<span class="chip-label">{{ m.label }}</span>
|
||||
<span class="chip-count">{{ m.count }} tools</span>
|
||||
<span class="chip-label">{{ tt(m.key) }}</span>
|
||||
<span class="chip-count">{{ m.count }} {{ tt("home.toolsSuffix") }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shared">
|
||||
<a v-for="s in shared" :key="s.href" class="scard" :href="s.href">
|
||||
<strong>{{ s.label }}</strong>
|
||||
<span>{{ s.sub }}</span>
|
||||
<strong>{{ tt(s.key) }}</strong>
|
||||
<span>{{ tt(s.subKey) }}</span>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Build-time per-section tool counts for the docs homepage chips. Derived from
|
||||
// the shared TOOLS catalog via toolSection() so the numbers never drift (the
|
||||
// project's single-source-of-truth rule for per-section counts). Runs in Node
|
||||
// during the VitePress build; only the resulting numbers reach the client.
|
||||
import { defineLoader } from "vitepress";
|
||||
import { TOOLS } from "../../../../packages/shared/src/constants.ts";
|
||||
import { type Section, toolSection } from "../../../../packages/shared/src/section.ts";
|
||||
|
||||
export type SectionCounts = Record<Section, number>;
|
||||
|
||||
declare const data: SectionCounts;
|
||||
|
||||
export { data };
|
||||
|
||||
export default defineLoader({
|
||||
load(): SectionCounts {
|
||||
const counts: SectionCounts = { image: 0, video: 0, audio: 0, pdf: 0, files: 0 };
|
||||
for (const tool of TOOLS) counts[toolSection(tool)]++;
|
||||
return counts;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user