diff --git a/.cursor/rules/localization-workflow.mdc b/.cursor/rules/localization-workflow.mdc new file mode 100644 index 0000000..a241acc --- /dev/null +++ b/.cursor/rules/localization-workflow.mdc @@ -0,0 +1,23 @@ +--- +description: Localization workflow and Lingo guardrails +alwaysApply: true +--- + +# Localization Workflow + +- Treat `en/` as the canonical source locale and keep identical file paths/filenames across all locales. +- Keep all locale navigation in `docs.json` under `navigation.languages`; do not reintroduce `navigation.global` or top-level `navbar`. +- Keep SEO metatags in English unless explicitly requested otherwise. +- Do not translate code snippets, inline code, URLs, route slugs, or API identifiers. +- Shared media lives under repo-root `assets/`. Locale pages are nested under `en/` or `es/` (typically `locale/
/.mdx`), so relative `src` paths must account for that depth (for example `../../assets/...`). `scripts/localize-internal-links.mjs` rewrites markdown `](/...)` and JSX `href="/..."` only: for whichever **`locale` is being processed**, bare paths get that prefix, and paths prefixed with **any other** locale from `i18n.json` are re-prefixed to that same active locale; it does not change media `src` or snippet imports. +- `scripts/localize-component-imports.mjs` rewrites MDX import paths that reference `/components/*.jsx` so each locale page imports its own locale components (for whichever locale is being processed). +- Shared MDX snippets live under repo-root `snippets/` (not per-locale). Import with relative paths from each page; do not add shared MDX snippets to Lingo `i18n.json` buckets unless you intend to translate them. +- Locale-aware React components that can contain text live under `[locale]/components/*.jsx` and are manually maintained per locale (do not add them to Lingo `i18n.json` buckets; do not keep these in shared `snippets/`). +- Keep a **single** OpenAPI document at repo-root `openapi.yml` (not under `en/` or `es/`). Endpoint and webhook MDX must use Mintlify’s form `openapi: openapi.yml ` or `openapi: openapi.yml webhook `. Do not translate the `openapi:` line. +- English **nav titles** for those pages come from OpenAPI **`summary`** via `scripts/sync-openapi-titles.mjs` (`npm run translate:sync-openapi-titles`). It runs at the start of `translate:generate` and on the translate-on-main workflow. Lingo translates the `title` field for target locales; the script only fills a missing target `title` with English (bootstrap)—it does not overwrite existing translations. +- Mintlify custom heading IDs use markdown `## Title {#slug}` (see Mintlify docs). Slugs are aligned from English via `scripts/sync-heading-anchors.mjs`. Never translate or alter `{#…}`; sync runs after Lingo in PIT/CI. Do not merge heading-count drift between `en/` and target locales without fixing structure first. +- Treat `lingo/brand-voice.md` as the source of truth for **brand voice** (tone and style) only. Do not add structural or tooling rules there (for example Mintlify `{#slug}` handling); those stay in this rule, README, and optionally in the Lingo engine **Instructions** field—not brand voice. +- Do not add script-based brand voice sync; sync brand voice on demand via Lingo MCP tools from chat. +- Treat `lingo/glossary.csv` as the glossary source of truth; use MCP sync with canonical key `sourceLocale|targetLocale|type|sourceText`. +- For glossary sync, always do dry-run first, show create/update/delete counts, then apply on confirmation. +- When adding locales, update both `docs.json` language blocks and `i18n.json` `locale.targets`. diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a8a86f --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Lingo.dev configuration +# Copy this file to .env and replace placeholders with real values. + +# Lingo.dev API key (used by CLI and CI workflows) +LINGO_API_KEY=lingo_sk_your_api_key_here + +# Lingo.dev localization engine id +LINGO_ENGINE_ID=eng_your_engine_id_here diff --git a/.github/workflows/translate-on-main.yml b/.github/workflows/translate-on-main.yml new file mode 100644 index 0000000..71e7189 --- /dev/null +++ b/.github/workflows/translate-on-main.yml @@ -0,0 +1,81 @@ +name: Translate on main + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + translate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Inject Lingo engine id into i18n config + env: + LINGO_ENGINE_ID: ${{ secrets.LINGO_ENGINE_ID }} + run: | + if [ -z "${LINGO_ENGINE_ID}" ]; then + echo "LINGO_ENGINE_ID secret is required." + exit 1 + fi + node -e 'const fs=require("fs");const p="i18n.json";const j=JSON.parse(fs.readFileSync(p,"utf8"));j.engineId=process.env.LINGO_ENGINE_ID;fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");' + + - name: Sync API and webhook titles from OpenAPI + run: node scripts/sync-openapi-titles.mjs + + - name: Run Lingo translation + env: + LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }} + run: | + locales=$(node -e 'const fs=require("fs");const j=JSON.parse(fs.readFileSync("i18n.json","utf8"));console.log((j.locale?.targets||[]).join(" "));') + if [ -z "$locales" ]; then + echo "No target locales configured in i18n.json locale.targets" + exit 1 + fi + for locale in $locales; do + echo "Running Lingo translation for $locale" + npx lingo.dev@latest run --target-locale "$locale" + done + + - name: Translate docs.json language-specific navigation + env: + LINGO_ENGINE_ID: ${{ secrets.LINGO_ENGINE_ID }} + LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }} + run: | + locales=$(node -e 'const fs=require("fs");const j=JSON.parse(fs.readFileSync("i18n.json","utf8"));console.log((j.locale?.targets||[]).join(" "));') + for locale in $locales; do + echo "Translating docs.json labels for $locale" + node scripts/translate-docs-json.mjs --target "$locale" + echo "Localizing internal links for $locale" + node scripts/localize-internal-links.mjs --target "$locale" + echo "Localizing component imports for $locale" + node scripts/localize-component-imports.mjs --target "$locale" + done + + - name: Sync Mintlify heading anchors + run: node scripts/sync-heading-anchors.mjs + + - name: Create translation PR + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(i18n): update translations from main" + title: "chore(i18n): update translations from main" + body: "Automated translation update from main branch push." + branch: "chore/i18n/auto-translations" + delete-branch: true diff --git a/.github/workflows/validate-translations.yml b/.github/workflows/validate-translations.yml new file mode 100644 index 0000000..0957519 --- /dev/null +++ b/.github/workflows/validate-translations.yml @@ -0,0 +1,73 @@ +name: Validate translations + +on: + pull_request: + paths: + - "docs.json" + - "i18n.json" + - "openapi.yml" + - "package.json" + - "en/**" + - "es/**" + - ".github/workflows/translate-on-main.yml" + - ".github/workflows/validate-translations.yml" + - "scripts/*.mjs" + +jobs: + validate: + if: ${{ !contains(fromJson('["translations-setup"]'), github.head_ref) }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Inject Lingo engine id into i18n config + env: + LINGO_ENGINE_ID: ${{ secrets.LINGO_ENGINE_ID }} + run: | + if [ -z "${LINGO_ENGINE_ID}" ]; then + echo "LINGO_ENGINE_ID is not set, skipping engine injection." + else + node -e 'const fs=require("fs");const p="i18n.json";const j=JSON.parse(fs.readFileSync(p,"utf8"));j.engineId=process.env.LINGO_ENGINE_ID;fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");' + fi + + - name: Validate translation parity and frontmatter + run: npm run translate:validate + + - name: Ensure internal links are locale-localized + run: npm run translate:localize-links:check + + - name: Ensure component imports are locale-localized + run: npm run translate:localize-component-imports:check + + - name: Ensure heading anchors match English slugs + run: npm run translate:sync-anchors:check + + - name: Ensure API and webhook titles match OpenAPI summaries + run: npm run translate:sync-openapi-titles:check + + - name: Mintlify validate + run: npx mint@latest validate + + - name: Check broken links + run: npx mint@latest broken-links --check-anchors --check-snippets + + - name: Translation freshness gate + env: + LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }} + LINGO_ENGINE_ID: ${{ secrets.LINGO_ENGINE_ID }} + run: | + if [ -z "${LINGO_API_KEY}" ] || [ -z "${LINGO_ENGINE_ID}" ]; then + echo "LINGO_API_KEY or LINGO_ENGINE_ID is not set, skipping frozen translation gate." + else + npx lingo.dev@latest run --frozen + fi diff --git a/.gitignore b/.gitignore index 28f1ba7..9bdf355 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -.DS_Store \ No newline at end of file +.DS_Store +.env \ No newline at end of file diff --git a/.vale.ini b/.vale.ini index 015677b..17c6a8b 100644 --- a/.vale.ini +++ b/.vale.ini @@ -16,6 +16,13 @@ mdx = md BasedOnStyles = Vale Vale.Terms = NO # Enforces really harsh capitalization rules, keep off +# Exclude Spanish locale pages from spellchecking. +# Vale may evaluate paths with leading directories, so match both forms. +[es/**/*.mdx] +Vale.Spelling = NO +[**/es/**/*.mdx] +Vale.Spelling = NO + # `import ...`, `export ...` # `` # `...` diff --git a/README.md b/README.md index 41bccb6..703969f 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,144 @@ npm run dev Our docs are written in [MDX](https://mdxjs.com/), which if you haven't used it before is a bit if markdown and React had a baby. See the [full guide](https://mintlify.com/docs/content/components) from Mintlify on writing MDX and the components that are available for how best to contribute. + +### Localization (Lingo.dev) + +This repository uses Lingo.dev for static-content localization of docs pages and language-specific navigation labels in `docs.json`. + +#### Install Lingo MCP in Cursor + +1. Open your Cursor MCP config file: + - `~/.cursor/mcp.json` +2. Add a Lingo MCP server entry: + +```json +{ + "mcpServers": { + "lingo": { + "url": "https://mcp.lingo.dev/account", + "headers": { + "x-api-key": "${env:LINGO_API_KEY}" + } + } + } +} +``` + +3. Ensure the API key is available to the Cursor app process as an environment variable: + - `LINGO_API_KEY` +4. Restart Cursor fully after updating `mcp.json` or environment variables. +5. Verify by asking Cursor chat to list engines from the Lingo MCP server. + +Notes: +- This repo ignores `.env` via `.cursorignore`, so Cursor agents should not read API keys from project files. +- If Cursor was launched before env vars were set, restart Cursor from a shell where env vars are already exported. + +#### Localization files + +- `lingo/glossary.csv`: Terms that must stay fixed or use specific translations. +- `lingo/brand-voice.md`: Single brand voice used for all locales. +- `scripts/translate-docs-json.mjs`: Translates language-specific `docs.json` navigation labels directly in the source-of-truth `docs.json`. Prefer `npm run translate:docs-json -- --target `. +- `scripts/localize-internal-links.mjs`: Rewrites internal absolute **navigation** links in each locale’s MDX for whatever locale is being processed (`--target`, `--all`, or default targets): bare paths get that locale’s prefix (e.g. `/platform/points` → `/es/platform/points` when processing `es/`), and any path already prefixed with **another** locale from `i18n.json` (`en`, `es`, future `fr`, etc.) is re-prefixed to the active locale (so `/en/...` or stale `/es/...` on a `fr/` page become `/fr/...`). Longer codes are matched first (e.g. `en-US` before `en`). It does **not** change relative image or video `src` paths, MDX/JSX **import** paths, or shared **MDX snippets**; those must be correct in source so all locales stay aligned. Prefer `npm run translate:localize-links --` with `--target`, `--all`, or `--all --check`. +- `scripts/localize-component-imports.mjs`: Rewrites MDX `import ... from "...//components/...jsx"` paths so they match the active locale being processed (`--target`, `--all`, or default targets). This keeps locale pages importing locale-local components after PIT/CI translation. +- **Shared MDX snippets (`snippets/*.mdx`)**: Reusable MDX blocks stay in repo-root `snippets/` (not per-locale). Import them with relative paths from each page (for example `../../snippets/foo.mdx` from `locale/
/.mdx`, or more `../` segments for deeper pages). They are excluded from Lingo buckets in `i18n.json` so they stay English and identical everywhere. +- **Localized React components (`[locale]/components/*.jsx`)**: UI components that can contain locale text are stored per locale (for example `en/components/rate-limit-badge.jsx`, `es/components/rate-limit-badge.jsx`) and manually maintained per locale (not translated by PIT/CI automation). +- **Media paths**: Pages live under `en/
/...` or `es/
/...`, while shared files sit in repo-root `assets/`. Use a path like `../../assets/...` from a typical `locale/
/.mdx` file. +- **`openapi.yml`**: One OpenAPI 3.1 spec at the **repository root** (alongside `docs.json`). API and webhook pages reference it explicitly in frontmatter, for example `openapi: openapi.yml get /users/{id}` or `openapi: openapi.yml webhook points.changed`. Do not duplicate the YAML under locale folders; Lingo must not alter `openapi:` lines. +- **`scripts/sync-openapi-titles.mjs`**: Copies each operation/webhook **`summary`** from `openapi.yml` into the English page’s **`title:`** frontmatter (Mintlify’s default when `title` is omitted). Target locales get an English `title` only if missing (bootstrap); run **`npm run translate:generate`** so Lingo translates those titles. Runs automatically at the start of `translate:generate` and in translate-on-main before Lingo. +- `scripts/sync-heading-anchors.mjs`: Writes Mintlify [custom heading IDs](https://www.mintlify.com/docs/create/text#custom-heading-ids) as **`## Title {#slug}`** markdown. Slugs match Mintlify’s auto rules from the **English** title so hashes like `#pro-plan` stay stable across locales. Run `npm run translate:sync-anchors` after bulk heading edits; translation pipelines run it automatically (see **Heading anchors and Lingo** below). The script can also migrate one-line **`

`** left from older tooling back to `{#slug}` syntax. +- `scripts/validate-glossary-csv.mjs`: Validates glossary schema and duplicate canonical keys. Prefer `npm run lingo:validate-glossary`. + +#### Heading anchors and Lingo + +- **Canonical behavior** is enforced by **`scripts/sync-heading-anchors.mjs`** (runs at the end of `translate:generate` and on the translate-on-main workflow). It rewrites `en/` from current English titles and reapplies the **same** `{#slug}` suffixes to translated headings in document order. Hash links stay aligned even if Lingo alters titles or fragments. +- **Lingo:** Keep **`lingo/brand-voice.md`** to tone and style only. For structural rules (for example Mintlify `{#slug}` on headings), optionally add a separate line in your Lingo engine **Instructions** field (not brand voice), such as: “Preserve `{#…}` heading fragments exactly—do not translate or alter the slug.” That only reduces churn; **the script + `translate:sync-anchors:check` are authoritative.** +- **Strict MDX parsers** (for example unconfigured `mdx-js`) can treat `{` as JSX and error on `{#slug}`; **Mintlify’s `mint dev` / deploy pipeline** is expected to handle this documented syntax. If you see Acorn errors locally, update the Mintlify CLI (`npm i -D mint@latest` or global `mintlify`) or check [Mintlify support](https://mintlify.com/docs); do not switch to raw HTML headings unless Mintlify asks you to. +- **Do not** fold this into `localize-internal-links.mjs`: that script only handles absolute path `href`s; heading anchors are a separate structural pass and must run **after** Lingo (and after link localization) so all three stay consistent. + +#### npm scripts (localization and checks) + +Use `npm run