diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 17c15e048..118fc3e20 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -181,7 +181,7 @@ jobs: TRUST: ${{ steps.pull-request.outputs.trust }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TRANSLATIONS_PROJECT_ID: ${{ vars.VERCEL_TRANSLATIONS_PROJECT_ID }} + VERCEL_TRANSLATIONS_PROJECT_ID: ${{ secrets.VERCEL_TRANSLATIONS_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | repository_owner="${GITHUB_REPOSITORY%%/*}" @@ -461,7 +461,7 @@ jobs: if [[ "$TRANSLATION_SCOPE" == "all" ]]; then if [[ -z "$VERCEL_TRANSLATIONS_PROJECT_ID" ]]; then - echo "docs-translations-all requires the VERCEL_TRANSLATIONS_PROJECT_ID repository variable." >&2 + echo "docs-translations-all requires the VERCEL_TRANSLATIONS_PROJECT_ID repository secret." >&2 exit 1 fi if ! translations_project_response="$( diff --git a/.github/workflows/site-production.yml b/.github/workflows/site-production.yml index 4516ac31b..4a3808735 100644 --- a/.github/workflows/site-production.yml +++ b/.github/workflows/site-production.yml @@ -25,6 +25,8 @@ on: required: true VERCEL_PROJECT_ID: required: true + VERCEL_TRANSLATIONS_PROJECT_ID: + required: false outputs: production_url: description: URL of the completed Vercel production deployment @@ -153,7 +155,7 @@ jobs: SITE_SHA: ${{ steps.site.outputs.sha }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TRANSLATIONS_PROJECT_ID: ${{ vars.VERCEL_TRANSLATIONS_PROJECT_ID }} + VERCEL_TRANSLATIONS_PROJECT_ID: ${{ secrets.VERCEL_TRANSLATIONS_PROJECT_ID }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | project_name() { @@ -233,7 +235,7 @@ jobs: if [[ "$DEPLOY_TRANSLATIONS" == "true" ]]; then if [[ -z "$VERCEL_TRANSLATIONS_PROJECT_ID" ]]; then - echo "Translation deployment requires the VERCEL_TRANSLATIONS_PROJECT_ID repository variable." >&2 + echo "Translation deployment requires the VERCEL_TRANSLATIONS_PROJECT_ID repository secret." >&2 exit 1 fi translations_project_name="$(project_name "$VERCEL_TRANSLATIONS_PROJECT_ID")" diff --git a/astro.config.ts b/astro.config.ts index fa9a7c50e..1f614acfc 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -94,7 +94,15 @@ export default defineConfig({ // Non-standard fence languages used by the generated reference docs. langAlias: { response: "text", result: "text", results: "text", test: "text", code: "text", - SQL: "sql", Python: "python", python3: "python", capnp: "text", + default: "text", hexdump: "text", promql: "text", vbnet: "text", + SQL: "sql", pgsql: "sql", prql: "sql", "sq;": "sql", + Python: "python", python3: "python", pycon: "python", + CMake: "cmake", conf: "ini", env: "dotenv", + gradle: "groovy", maven: "xml", sbt: "scala", + "application/x-ndjson": "json", + capnp: "text", + // Historical fence typos remain in translated and remote content. + javascripton: "json", repsonse: "text", }, transformers: [clickhouseSqlTransformer()], }, diff --git a/bin/check-gt-navigation.ts b/bin/check-gt-navigation.ts new file mode 100644 index 000000000..02ae330a5 --- /dev/null +++ b/bin/check-gt-navigation.ts @@ -0,0 +1,214 @@ +import fs from "node:fs"; +import path from "node:path"; + +const repositoryRoot = process.cwd(); +const configPath = path.join(repositoryRoot, "gt.config.json"); +const docsPath = path.join(repositoryRoot, "docs.json"); +const uiStringsPath = path.join(repositoryRoot, "i18n", "en", "ui.json"); +const translatableKeys = [ + "group", + "tab", + "item", + "anchor", + "dropdown", +] as const; +const expectedSelectors = translatableKeys.map((key) => `$..${key}`); +const pagePathMatch = "^/?([^\\s]+)$"; + +function fail(message: string): never { + throw new Error(`check-gt-navigation: ${message}`); +} + +function readJson(filePath: string): unknown { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function asRecord( + value: unknown, + description: string, +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${description} must be an object`); + } + + return value as Record; +} + +function assertTranslationSchema(): void { + const config = asRecord(readJson(configPath), "gt.config.json"); + const options = asRecord(config.options, "gt.config.json options"); + const jsonSchema = asRecord( + options.jsonSchema, + "gt.config.json options.jsonSchema", + ); + const docsSchema = asRecord( + jsonSchema["./docs.json"], + "the ./docs.json translation schema", + ); + const files = asRecord(config.files, "gt.config.json files"); + const jsonFiles = asRecord(files.json, "gt.config.json JSON files"); + + if ( + !Array.isArray(jsonFiles.include) || + !jsonFiles.include.includes("./i18n/[locale]/ui.json") + ) { + fail("the JSON translation inputs must include ./i18n/[locale]/ui.json"); + } + + if (docsSchema.resolveRefs !== true) { + fail( + "the ./docs.json translation schema must resolve navigation $ref files", + ); + } + + const composite = asRecord( + docsSchema.composite, + "the ./docs.json composite schema", + ); + const languages = asRecord( + composite["$.navigation.languages"], + "the $.navigation.languages composite schema", + ); + + if ( + languages.type !== "array" || + languages.key !== "$.language" || + languages.splitEntries !== true + ) { + fail( + "the language navigation schema must remain a split array keyed by $.language", + ); + } + + if (!Array.isArray(languages.include)) { + fail("the language navigation schema must declare its translatable fields"); + } + + const included = new Set(languages.include); + for (const selector of expectedSelectors) { + if (!included.has(selector)) { + fail(`the language navigation schema must include ${selector}`); + } + } + + const transform = asRecord( + languages.transform, + "the language navigation path transforms", + ); + const pagesRule = asRecord( + transform["$..pages[*]"], + "the $..pages[*] transform", + ); + if ( + pagesRule.match !== pagePathMatch || + pagesRule.replace !== "{locale}/$1" + ) { + fail( + "$..pages[*] must localize page paths without matching OpenAPI operation pointers", + ); + } + + const rootRule = asRecord(transform["$..root"], "the $..root transform"); + if (rootRule.match !== "^/?(.*)$" || rootRule.replace !== "{locale}/$1") { + fail("$..root must map navigation roots to {locale}/$1"); + } +} + +function assertUiStrings(): void { + const ui = asRecord(readJson(uiStringsPath), "the English UI dictionary"); + for (const section of [ + "account", + "actions", + "navigation", + "search", + "status", + "theme", + "toc", + ]) { + const messages = asRecord( + ui[section], + `the English UI dictionary's ${section} section`, + ); + if (Object.keys(messages).length === 0) { + fail(`the English UI dictionary's ${section} section must not be empty`); + } + for (const [key, value] of Object.entries(messages)) { + if (typeof value !== "string" || value.trim() === "") { + fail( + `the English UI dictionary entry ${section}.${key} must be a non-empty string`, + ); + } + } + } +} + +function assertNavigationCoverage(): void { + const docs = asRecord(readJson(docsPath), "docs.json"); + const navigation = asRecord(docs.navigation, "docs.json navigation"); + if (!Array.isArray(navigation.languages)) { + fail("docs.json navigation.languages must be an array"); + } + + const english = navigation.languages.find((entry) => { + return Boolean( + entry && + typeof entry === "object" && + !Array.isArray(entry) && + entry.language === "en", + ); + }); + if (!english) { + fail("docs.json must contain an English navigation entry"); + } + + const visitedFiles = new Set(); + let labelCount = 0; + + function visit(value: unknown, sourceDirectory: string): void { + if (Array.isArray(value)) { + for (const entry of value) visit(entry, sourceDirectory); + return; + } + + if (!value || typeof value !== "object") return; + const record = value as Record; + + if (typeof record.$ref === "string") { + const referencedPath = path.resolve(sourceDirectory, record.$ref); + const relativePath = path.relative(repositoryRoot, referencedPath); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + fail(`navigation reference escapes the repository: ${record.$ref}`); + } + if (!fs.existsSync(referencedPath)) { + fail(`navigation reference does not exist: ${relativePath}`); + } + if (visitedFiles.has(referencedPath)) return; + visitedFiles.add(referencedPath); + visit(readJson(referencedPath), path.dirname(referencedPath)); + return; + } + + for (const [key, entry] of Object.entries(record)) { + if (translatableKeys.includes(key as (typeof translatableKeys)[number])) { + if (typeof entry !== "string" || entry.trim() === "") { + fail(`${key} in the English navigation must be a non-empty string`); + } + labelCount += 1; + } + visit(entry, sourceDirectory); + } + } + + visit(english, repositoryRoot); + if (labelCount === 0) { + fail("the English navigation contains no translatable labels"); + } + + console.log( + `check-gt-navigation: ${labelCount} labels across ${visitedFiles.size} referenced navigation files`, + ); +} + +assertTranslationSchema(); +assertUiStrings(); +assertNavigationCoverage(); diff --git a/gt.config.json b/gt.config.json index 0dc0fa9a6..5a4f86feb 100644 --- a/gt.config.json +++ b/gt.config.json @@ -15,7 +15,32 @@ }, "jsonSchema": { "./docs.json": { - "preset": "mintlify-hide-default" + "resolveRefs": true, + "composite": { + "$.navigation.languages": { + "type": "array", + "key": "$.language", + "experimentalSort": "localesAlphabetical", + "splitEntries": true, + "include": [ + "$..group", + "$..tab", + "$..item", + "$..anchor", + "$..dropdown" + ], + "transform": { + "$..pages[*]": { + "match": "^/?([^\\s]+)$", + "replace": "{locale}/$1" + }, + "$..root": { + "match": "^/?(.*)$", + "replace": "{locale}/$1" + } + } + } + } }, "./_specs/cloud-openapi.json": { "preset": "openapi" @@ -35,6 +60,7 @@ "json": { "include": [ "./docs.json", + "./i18n/[locale]/ui.json", "./_specs/cloud-openapi.json" ], "exclude": [ @@ -119,4 +145,4 @@ ], "defaultLocale": "en", "$schema": "https://assets.gtx.dev/config-schema.json" -} \ No newline at end of file +} diff --git a/i18n/en/ui.json b/i18n/en/ui.json new file mode 100644 index 000000000..dee4a6990 --- /dev/null +++ b/i18n/en/ui.json @@ -0,0 +1,63 @@ +{ + "account": { + "clickHouseOnGitHub": "ClickHouse on GitHub", + "getStarted": "Get Started", + "signIn": "Sign in" + }, + "actions": { + "choosePath": "Choose your path", + "copied": "Copied", + "copyFailed": "Couldn't copy", + "copyPage": "Copy page", + "editPage": "Edit this page", + "reportIssue": "Report an issue", + "setupDocsMcp": "Set up docs MCP", + "updated": "Updated", + "view": "View", + "viewMarkdown": "View as Markdown" + }, + "navigation": { + "breadcrumb": "Breadcrumb", + "chooseLanguage": "Choose documentation language", + "close": "Close navigation", + "documentationTools": "Documentation tools", + "home": "Home", + "homeAria": "ClickHouse Documentation home page", + "navigation": "Navigation", + "next": "Next", + "open": "Open navigation", + "pages": "Pages", + "pagination": "Pagination", + "previous": "Previous", + "primary": "Primary navigation", + "site": "Site navigation", + "toggleSection": "Toggle {label} section" + }, + "search": { + "ask": "Ask", + "askAi": "Ask AI", + "askAiAboutClickHouse": "Ask AI about ClickHouse", + "filter": "Filter…", + "filterNavigation": "Filter navigation", + "search": "Search", + "searchDocumentation": "Search documentation", + "searchShort": "Search…" + }, + "status": { + "draft": "Draft", + "forHumans": "For humans", + "loading": "Loading…" + }, + "theme": { + "dark": "Switch to dark theme", + "light": "Switch to light theme", + "preference": "Theme preference", + "system": "Switch to system theme" + }, + "toc": { + "jumpToSection": "Jump to section", + "onThisPage": "On this page", + "overview": "Overview", + "tableOfContents": "Table of contents" + } +} diff --git a/package.json b/package.json index 8b45d2545..0bd3fba40 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "packageManager": "pnpm@10.20.0", "scripts": { - "prepare:site": "node bin/prepare-public.ts && node bin/fetch-openapi.ts && node bin/gen-redirects.ts && node bin/gen-compat-wrappers.ts && node bin/gen-import-index.ts && node bin/gen-sidebar.ts && node bin/gen-galaxy-script.ts && node bin/gen-clickhouse-sql-keywords.ts && node bin/gen-homepage-locales.ts && node bin/gen-homepage-react.ts && node bin/gen-changelog-entries.ts", + "prepare:site": "node bin/check-gt-navigation.ts && node bin/prepare-public.ts && node bin/fetch-openapi.ts && node bin/gen-redirects.ts && node bin/gen-compat-wrappers.ts && node bin/gen-import-index.ts && node bin/gen-sidebar.ts && node bin/gen-galaxy-script.ts && node bin/gen-clickhouse-sql-keywords.ts && node bin/gen-homepage-locales.ts && node bin/gen-homepage-react.ts && node bin/gen-changelog-entries.ts", "prebuild": "node bin/fetch-remotes.ts && pnpm run prepare:site", "predev": "node bin/fetch-remotes.ts && pnpm run prepare:site", "dev": "astro dev", @@ -23,6 +23,7 @@ "deploy": "node bin/wrangler-locale.ts deploy", "deploy:preview": "node bin/wrangler-locale.ts preview", "postbuild": "node bin/postbuild.ts", + "check:gt-navigation": "node bin/check-gt-navigation.ts", "check:mdx": "node bin/measure/mdx-compile-check.ts --refs get-started concepts guides reference products clickstack integrations resources chdb snippets", "measure": "node bin/measure/page-weight.ts ${DOCS_OUT_DIR:-dist}/docs && node bin/measure/anchor-parity.ts ${DOCS_OUT_DIR:-dist}/docs && node bin/measure/base-check.ts ${DOCS_OUT_DIR:-dist}/docs && node bin/measure/url-parity.ts ${DOCS_OUT_DIR:-dist}/docs" }, diff --git a/src/components/AskAiButton.astro b/src/components/AskAiButton.astro index ec2bb2b23..3d51f54ca 100644 --- a/src/components/AskAiButton.astro +++ b/src/components/AskAiButton.astro @@ -1,12 +1,15 @@ --- import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; +import { uiStrings } from "@/lib/ui-strings.server"; interface Props { /** Sidebar controls are also shown inside the mobile navigation drawer. */ sidebar?: boolean; + currentLocale?: string; } -const { sidebar = false } = Astro.props; +const { sidebar = false, currentLocale } = Astro.props; +const messages = uiStrings(currentLocale); --- diff --git a/src/components/Header.astro b/src/components/Header.astro index 4a127b0e2..f62c5b8c2 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -12,6 +12,7 @@ import HomepageNavigation from "./HomepageNavigation.astro"; import HomepageNavbarCta from "./HomepageNavbarCta.astro"; import HomepageLogoLight from "../../_site/logo/light.svg"; import HomepageLogoDark from "../../_site/logo/dark.svg"; +import { uiStrings } from "@/lib/ui-strings.server"; interface Props { /** Astro collection id for the current page, forwarded from DocsLayout. */ @@ -42,6 +43,7 @@ const homepagePrefix = currentLocale && currentLocale.toLowerCase() !== "en" : ""; const homepageHref = (href: string) => withBase(`${homepagePrefix}${href === "/" ? "" : href}` || "/"); const homeHref = readScope().remotePreview ? "https://clickhouse.com/docs/" : homepageHref("/"); +const messages = uiStrings(currentLocale); ---
@@ -55,27 +57,27 @@ const homeHref = readScope().remotePreview ? "https://clickhouse.com/docs/" : ho )}
- + ClickHouse Docs ClickHouse Docs - +
{homepage ? ( - + ) : ( <>
{activeLocales && activeLocales.length > 0 && } - {!showSidebar && config.search !== false && } - {!showSidebar && } - + {!showSidebar && config.search !== false && } + {!showSidebar && } +
- + )} {showSidebar && ( @@ -84,7 +86,7 @@ const homeHref = readScope().remotePreview ? "https://clickhouse.com/docs/" : ho shape="square" size="sm" icon="ph:list" - aria-label="Open navigation" + aria-label={messages.navigation.open} data-menu-btn class="lg:hidden text-muted-foreground" /> diff --git a/src/components/HomepageNavbarCta.astro b/src/components/HomepageNavbarCta.astro index b4699f1fa..466efbe47 100644 --- a/src/components/HomepageNavbarCta.astro +++ b/src/components/HomepageNavbarCta.astro @@ -1,4 +1,12 @@ --- +import { uiStrings } from "@/lib/ui-strings.server"; + +interface Props { + currentLocale?: string; +} + +const { currentLocale } = Astro.props; +const messages = uiStrings(currentLocale); const signInUrl = "https://console.clickhouse.cloud/signIn?loc=docs-nav-signIn-cta"; const signUpUrl = "https://clickhouse.cloud/signUp?loc=docs-nav-signUp-cta"; --- @@ -9,7 +17,7 @@ const signUpUrl = "https://clickhouse.cloud/signUp?loc=docs-nav-signUp-cta"; target="_blank" rel="noopener" class="ch-gh-stars" - aria-label="ClickHouse on GitHub" + aria-label={messages.account.clickHouseOnGitHub} >
diff --git a/src/components/HomepageNavigation.astro b/src/components/HomepageNavigation.astro index deb826a1e..6eb40b84d 100644 --- a/src/components/HomepageNavigation.astro +++ b/src/components/HomepageNavigation.astro @@ -3,6 +3,7 @@ import Icon from "@cloudflare/nimbus-docs/components/Icon.astro"; import { withBase } from "@/lib/base"; import { localeRouteName } from "@/util/locales"; import { readScope } from "@/lib/scope"; +import { uiStrings } from "@/lib/ui-strings.server"; import fs from "node:fs"; import path from "node:path"; @@ -36,7 +37,8 @@ if (!navigationFile) throw new Error(`Unsupported navigation locale "${currentLo const navigation = JSON.parse( fs.readFileSync(path.join(process.cwd(), "src/generated", navigationFile), "utf8"), ) as NavItem[]; -const tabs = navigation.filter((tab) => tab.label !== "Home"); +const tabs = navigation.slice(1); +const messages = uiStrings(currentLocale); const localePrefix = currentLocale.toLowerCase() === "en" ? "" : `/${localeRouteName(currentLocale)}`; const currentPath = Astro.url.pathname.replace(/\/+$/, "") || "/"; const remotePreview = readScope().remotePreview; @@ -69,15 +71,17 @@ function firstDestination(item: NavItem): string | undefined { type MenuEntry = { label: string; href?: string; icon?: string; section?: boolean }; -const iconPaths: Record> = { - Database: ["/images/icons/icon-get-started.svg", "/images/icons/icon-concepts.svg", "/images/icons/icon-guides.svg", "/images/icons/icon-reference.svg"], - Solutions: [undefined, "/images/icons/icon-clickhouse-cloud.svg", "/images/icons/icon-postgres.svg", "/images/icons/logo-langfuse.svg", undefined, "/images/icons/icon-clickstack.svg", "/images/icons/icon-agentic-data-stack.svg", "/images/icons/icon-chdb.svg", "/images/icons/icon-kubernetes-operator.svg"], - Integrations: [undefined, "/images/icons/icon-clickpipes.svg", "/images/icons/icon-language-clients.svg", "/images/icons/icon-connectors.svg"], - Resources: ["/images/icons/icon-support-center.svg", "/images/icons/icon-contribute.svg", "/images/icons/icon-changelogs.svg", "/images/icons/icon-about.svg"], +const tabKinds = ["database", "solutions", "integrations", "resources"] as const; +type TabKind = (typeof tabKinds)[number]; +const iconPaths: Record> = { + database: ["/images/icons/icon-get-started.svg", "/images/icons/icon-concepts.svg", "/images/icons/icon-guides.svg", "/images/icons/icon-reference.svg"], + solutions: [undefined, "/images/icons/icon-clickhouse-cloud.svg", "/images/icons/icon-postgres.svg", "/images/icons/logo-langfuse.svg", undefined, "/images/icons/icon-clickstack.svg", "/images/icons/icon-agentic-data-stack.svg", "/images/icons/icon-chdb.svg", "/images/icons/icon-kubernetes-operator.svg"], + integrations: [undefined, "/images/icons/icon-clickpipes.svg", "/images/icons/icon-language-clients.svg", "/images/icons/icon-connectors.svg"], + resources: ["/images/icons/icon-support-center.svg", "/images/icons/icon-contribute.svg", "/images/icons/icon-changelogs.svg", "/images/icons/icon-about.svg"], }; -function menuEntries(tab: NavItem): MenuEntry[] { - if (tab.label === "Solutions") { +function menuEntries(tab: NavItem, kind: TabKind): MenuEntry[] { + if (kind === "solutions") { return (tab.items ?? []).flatMap((section) => [ { label: section.label, section: true }, ...(section.items ?? []).map((item) => ({ label: item.label, href: firstDestination(item) })), @@ -87,16 +91,18 @@ function menuEntries(tab: NavItem): MenuEntry[] { } --- -