Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/site-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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%%/*}"
Expand Down Expand Up @@ -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="$(
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/site-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")"
Expand Down
10 changes: 9 additions & 1 deletion astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
},
Expand Down
214 changes: 214 additions & 0 deletions bin/check-gt-navigation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
fail(`${description} must be an object`);
}

return value as Record<string, unknown>;
}

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<string>();
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<string, unknown>;

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();
30 changes: 28 additions & 2 deletions gt.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Comment thread
Blargian marked this conversation as resolved.
"$..root": {
"match": "^/?(.*)$",
"replace": "{locale}/$1"
}
}
}
}
},
"./_specs/cloud-openapi.json": {
"preset": "openapi"
Expand All @@ -35,6 +60,7 @@
"json": {
"include": [
"./docs.json",
"./i18n/[locale]/ui.json",
"./_specs/cloud-openapi.json"
],
"exclude": [
Expand Down Expand Up @@ -119,4 +145,4 @@
],
"defaultLocale": "en",
"$schema": "https://assets.gtx.dev/config-schema.json"
}
}
63 changes: 63 additions & 0 deletions i18n/en/ui.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading