From 39f5d7f8c00f28f0ccef04f934a78160b0dea845 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 20 Jul 2026 16:01:45 -0600 Subject: [PATCH 1/7] feat(docs): crowdin translations setup --- .github/workflows/deploy-docs.yml | 5 + crowdin.yml | 23 ++++ docs/astro.config.mjs | 21 +++ docs/package.json | 1 + docs/plugins/remark-localize-content.mjs | 127 ++++++++++++++++++ docs/plugins/remark-localize-content.test.mjs | 74 ++++++++++ docs/scripts/verify-deploy-output.mjs | 20 +++ docs/src/config/sidebar.ts | 40 ++++++ docs/src/content.config.ts | 41 +++++- .../content/docs/contributing/translations.md | 21 ++- .../docs/development/Documentation/index.mdx | 22 +-- docs/src/content/docs/index.mdx | 2 +- docs/src/content/i18n/en.json | 73 +++++----- docs/src/lib/base-path.ts | 9 ++ docs/src/lib/components/DownloadOptions.astro | 69 +++++----- docs/src/lib/components/EditLink.astro | 32 +++++ docs/src/lib/components/Footer.astro | 2 +- docs/src/lib/components/MarkdownContent.astro | 37 +++++ docs/src/lib/components/SettingsDocs.astro | 10 +- .../components/SystemRequirmentsLink.astro | 16 ++- 20 files changed, 534 insertions(+), 111 deletions(-) create mode 100644 crowdin.yml create mode 100644 docs/plugins/remark-localize-content.mjs create mode 100644 docs/plugins/remark-localize-content.test.mjs create mode 100644 docs/src/lib/components/EditLink.astro create mode 100644 docs/src/lib/components/MarkdownContent.astro diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 0e9c5774ba3..1a230368719 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -51,6 +51,7 @@ jobs: filters: | docs: - '.github/workflows/deploy-docs.yml' + - 'crowdin.yml' - 'docs/**' - 'scripts/generate_docs_json.py' - 'invokeai/app/**' @@ -111,6 +112,10 @@ jobs: run: pnpm run check-docs-data working-directory: docs + - name: test localization helpers + run: pnpm run test:localization + working-directory: docs + - name: build docs run: pnpm build working-directory: docs diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000000..10d372206dc --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,23 @@ +preserve_hierarchy: true + +files: + - source: /docs/src/content/docs/index.mdx + translation: /docs/src/content/docs/%two_letters_code%/%original_file_name% + + - source: /docs/src/content/docs/start-here/**/*.{md,mdx} + translation: /docs/src/content/docs/%two_letters_code%/start-here/**/%original_file_name% + + - source: /docs/src/content/docs/configuration/**/*.{md,mdx} + translation: /docs/src/content/docs/%two_letters_code%/configuration/**/%original_file_name% + + - source: /docs/src/content/docs/concepts/**/*.{md,mdx} + translation: /docs/src/content/docs/%two_letters_code%/concepts/**/%original_file_name% + + - source: /docs/src/content/docs/features/**/*.{md,mdx} + translation: /docs/src/content/docs/%two_letters_code%/features/**/%original_file_name% + + - source: /docs/src/content/docs/troubleshooting/**/*.{md,mdx} + translation: /docs/src/content/docs/%two_letters_code%/troubleshooting/**/%original_file_name% + + - source: /docs/src/content/i18n/en.json + translation: /docs/src/content/i18n/%two_letters_code%.json diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ebb59d36115..1984590b58a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -7,6 +7,7 @@ import starlightLinksValidator from 'starlight-links-validator'; import starlightLlmsText from 'starlight-llms-txt'; import starlightChangelogs from 'starlight-changelogs'; import { rehypePrefixBaseToRootLinks } from './plugins/rehype-prefix-base-to-root-links.mjs'; +import { remarkLocalizeContent } from './plugins/remark-localize-content.mjs'; import starlightContextualMenu from 'starlight-contextual-menu'; // Configs @@ -34,6 +35,7 @@ export default defineConfig({ site, base: base || undefined, markdown: { + remarkPlugins: [[remarkLocalizeContent, { locales: ['de', 'es', 'hi'] }]], rehypePlugins: [[rehypePrefixBaseToRootLinks, { base }]], }, integrations: [ @@ -41,6 +43,9 @@ export default defineConfig({ // Content title: { en: 'InvokeAI Documentation', + de: 'InvokeAI-Dokumentation', + es: 'Documentación de InvokeAI', + hi: 'InvokeAI दस्तावेज़', }, logo: { src: './src/assets/invoke-icon-wide.svg', @@ -58,6 +63,18 @@ export default defineConfig({ label: 'English', lang: 'en', }, + de: { + label: 'Deutsch', + lang: 'de', + }, + es: { + label: 'Español', + lang: 'es', + }, + hi: { + label: 'हिन्दी', + lang: 'hi', + }, }, social: socialConfig, tableOfContents: { @@ -73,12 +90,16 @@ export default defineConfig({ ThemeProvider: './src/lib/components/ForceDarkTheme.astro', ThemeSelect: './src/lib/components/EmptyComponent.astro', Footer: './src/lib/components/Footer.astro', + EditLink: './src/lib/components/EditLink.astro', + MarkdownContent: './src/lib/components/MarkdownContent.astro', PageFrame: './src/layouts/PageFrameExtended.astro', }, plugins: [ starlightLinksValidator({ errorOnRelativeLinks: false, errorOnLocalLinks: false, + // The validator only knows content collection routes, not custom Astro pages. + exclude: ['/download/'], }), starlightLlmsText(), starlightChangelogs(), diff --git a/docs/package.json b/docs/package.json index 29d0786e8ea..23f700f22e5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,6 +9,7 @@ "check-docs-data": "pnpm run generate-docs-data && git diff --exit-code -- src/generated", "check-deploy-output": "node ./scripts/verify-deploy-output.mjs", "check-redirects": "node ./scripts/validate-redirect-targets.mjs", + "test:localization": "node --test ./plugins/remark-localize-content.test.mjs", "build": "astro build", "preview": "astro preview", "astro": "astro" diff --git a/docs/plugins/remark-localize-content.mjs b/docs/plugins/remark-localize-content.mjs new file mode 100644 index 00000000000..86f22516963 --- /dev/null +++ b/docs/plugins/remark-localize-content.mjs @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; + +const relativeImportPattern = /(from\s+|import\s*)(['"])(\.\.?\/[^'"]+)\2/g; + +export function remarkLocalizeContent(options = {}) { + const locales = new Set(options.locales ?? []); + + return (tree, file) => { + const context = getLocalizedFileContext(file?.path, locales); + + if (!context) { + return; + } + + walk(tree, (node) => { + if (node.type === 'link' && typeof node.url === 'string') { + node.url = localizeRootLink(node.url, context.locale); + } + + if (node.type === 'image' && typeof node.url === 'string') { + node.url = pointToSourceAsset(node.url, context); + } + + if (node.type === 'mdxjsEsm' && typeof node.value === 'string') { + node.value = node.value.replace(relativeImportPattern, (match, prefix, quote, specifier) => { + const localizedSpecifier = pointToSourceAsset(specifier, context); + return `${prefix}${quote}${localizedSpecifier}${quote}`; + }); + } + + if (node.type === 'mdxJsxAttribute' && typeof node.value === 'string') { + if (node.name === 'href' || node.name === 'link') { + node.value = localizeRootLink(node.value, context.locale); + } else if (node.name === 'src') { + node.value = pointToSourceAsset(node.value, context); + } + } + }); + }; +} + +function getLocalizedFileContext(filePath, locales) { + if (typeof filePath !== 'string') { + return undefined; + } + + const normalizedPath = filePath.split(sep).join('/'); + const docsMarker = '/src/content/docs/'; + const markerIndex = normalizedPath.lastIndexOf(docsMarker); + + if (markerIndex === -1) { + return undefined; + } + + const docsRoot = normalizedPath.slice(0, markerIndex + docsMarker.length - 1); + const pathWithinDocs = normalizedPath.slice(markerIndex + docsMarker.length); + const [locale, ...sourceSegments] = pathWithinDocs.split('/'); + + if (!locales.has(locale) || sourceSegments.length === 0) { + return undefined; + } + + return { + locale, + localizedDirectory: dirname(normalizedPath), + sourceDirectory: dirname(resolve(docsRoot, ...sourceSegments)), + }; +} + +function localizeRootLink(url, locale) { + if (!url.startsWith('/') || url.startsWith('//')) { + return url; + } + + if ( + url === `/${locale}` || + url.startsWith(`/${locale}/`) || + url === '/download' || + url.startsWith('/download/') + ) { + return url; + } + + return `/${locale}${url}`; +} + +function pointToSourceAsset(url, context) { + if (!url.startsWith('./') && !url.startsWith('../')) { + return url; + } + + const suffixIndex = url.search(/[?#]/); + const pathPart = suffixIndex === -1 ? url : url.slice(0, suffixIndex); + const suffix = suffixIndex === -1 ? '' : url.slice(suffixIndex); + const sourceTarget = resolve(context.sourceDirectory, pathPart); + + if (!existsSync(sourceTarget)) { + return url; + } + + const localizedPath = relative(context.localizedDirectory, sourceTarget).split(sep).join('/'); + const normalizedPath = localizedPath.startsWith('.') ? localizedPath : `./${localizedPath}`; + return `${normalizedPath}${suffix}`; +} + +function walk(node, visitor) { + if (!node || typeof node !== 'object') { + return; + } + + visitor(node); + + if (!Array.isArray(node.children)) { + return; + } + + for (const child of node.children) { + walk(child, visitor); + } +} + +export const testing = { + getLocalizedFileContext, + localizeRootLink, + pointToSourceAsset, +}; diff --git a/docs/plugins/remark-localize-content.test.mjs b/docs/plugins/remark-localize-content.test.mjs new file mode 100644 index 00000000000..010d2e4b502 --- /dev/null +++ b/docs/plugins/remark-localize-content.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { remarkLocalizeContent, testing } from './remark-localize-content.mjs'; + +const docsRoot = fileURLToPath(new URL('../src/content/docs/', import.meta.url)); + +test('localizes links and reuses source assets in translated MDX', () => { + const tree = { + type: 'root', + children: [ + { type: 'link', url: '/concepts/prompting-guide/', children: [] }, + { type: 'link', url: 'https://example.com/', children: [] }, + { type: 'image', url: './assets/gallery.png', children: [] }, + { + type: 'mdxJsxFlowElement', + children: [ + { type: 'mdxJsxAttribute', name: 'href', value: '/troubleshooting/faq/' }, + { type: 'mdxJsxAttribute', name: 'href', value: '/download/' }, + ], + }, + ], + }; + + remarkLocalizeContent({ locales: ['de', 'es', 'hi'] })(tree, { + path: `${docsRoot}es/features/gallery.mdx`, + }); + + assert.equal(tree.children[0].url, '/es/concepts/prompting-guide/'); + assert.equal(tree.children[1].url, 'https://example.com/'); + assert.equal(tree.children[2].url, '../../features/assets/gallery.png'); + assert.equal(tree.children[3].children[0].value, '/es/troubleshooting/faq/'); + assert.equal(tree.children[3].children[1].value, '/download/'); +}); + +test('rewrites relative MDX asset imports to the English source tree', () => { + const tree = { + type: 'root', + children: [ + { + type: 'mdxjsEsm', + value: "import splashImage from './assets/invoke-webui-canvas.png';", + children: [], + }, + ], + }; + + remarkLocalizeContent({ locales: ['de', 'es', 'hi'] })(tree, { + path: `${docsRoot}hi/index.mdx`, + }); + + assert.equal( + tree.children[0].value, + "import splashImage from '../assets/invoke-webui-canvas.png';", + ); +}); + +test('leaves English source files unchanged', () => { + const tree = { + type: 'root', + children: [{ type: 'link', url: '/configuration/docker/', children: [] }], + }; + + remarkLocalizeContent({ locales: ['de', 'es', 'hi'] })(tree, { + path: `${docsRoot}index.mdx`, + }); + + assert.equal(tree.children[0].url, '/configuration/docker/'); + assert.equal( + testing.getLocalizedFileContext(`${docsRoot}it/index.mdx`, new Set(['de', 'es', 'hi'])), + undefined, + ); +}); diff --git a/docs/scripts/verify-deploy-output.mjs b/docs/scripts/verify-deploy-output.mjs index 885d1f44baa..037646a07e7 100644 --- a/docs/scripts/verify-deploy-output.mjs +++ b/docs/scripts/verify-deploy-output.mjs @@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs'; const deployTarget = process.env.DEPLOY_TARGET ?? 'custom'; const base = deployTarget === 'ghpages' ? '/InvokeAI' : ''; const withBase = (path) => `${base}${path}`; +const siteUrl = (path) => + deployTarget === 'ghpages' ? `https://invoke-ai.github.io${base}${path}` : `https://invoke.ai${path}`; const expectations = [ { @@ -45,6 +47,24 @@ const expectations = [ }, ]; +for (const locale of ['de', 'es', 'hi']) { + expectations.push({ + file: `${locale}/start-here/installation/index.html`, + includes: [ + ` - - src/ - - content/ - - docs/ - - start-here/ - - installation.mdx - - i18n/ - - zh-CN Country code here - - start-here/ - - installation.mdx - - -We recommend simply copy/pasting the file and rewriting the text from there. - -Learn more about the intricacies of translating Astro Starlight docs [here](https://starlight.astro.build/guides/i18n). +Do not manually edit exported locale files. Make English documentation changes in GitHub and translation changes in Crowdin. Crowdin maintains a translation pull request, and the documentation workflow builds every locale before the PR is merged. ## Running a Build diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index a0c7e3e67c6..7cfbf5357b4 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -84,7 +84,7 @@ Whether you are looking to install the app, get support, train your own models, Ready to dive in? The [Invoke Launcher](/start-here/installation/) is the fastest way to get up and running on Windows, macOS, and Linux. For advanced setups, try [Docker](/configuration/docker/) or a [manual Python installation](/start-here/manual/). - + Get Invoke diff --git a/docs/src/content/i18n/en.json b/docs/src/content/i18n/en.json index 69333e3a0b2..e9f13c64db2 100644 --- a/docs/src/content/i18n/en.json +++ b/docs/src/content/i18n/en.json @@ -1,45 +1,32 @@ { - "skipLink.label": "Skip to content", - "search.label": "Search", - "search.ctrlKey": "Ctrl", - "search.cancelLabel": "Cancel", - "search.devWarning": "Search is only available in production builds. \nTry building and previewing the site to test it out locally.", - "themeSelect.accessibleLabel": "Select theme", - "themeSelect.dark": "Dark", - "themeSelect.light": "Light", - "themeSelect.auto": "Auto", - "languageSelect.accessibleLabel": "Select language", - "menuButton.accessibleLabel": "Menu", - "sidebarNav.accessibleLabel": "Main", - "tableOfContents.onThisPage": "On this page", - "tableOfContents.overview": "Overview", - "i18n.untranslatedContent": "This content is not available in your language yet.", - "page.editLink": "Edit page", - "page.lastUpdated": "Last updated:", - "page.previousLink": "Previous", - "page.nextLink": "Next", - "page.draft": "This content is a draft and will not be included in production builds.", - "404.text": "Page not found. Check the URL or try using the search bar.", - "aside.note": "Note", - "aside.tip": "Tip", - "aside.caution": "Caution", - "aside.danger": "Danger", - "fileTree.directory": "Directory", - "builtWithStarlight.label": "Built with Starlight", - "heading.anchorLabel": "Section titled “{{title}}”", - - "expressiveCode.copyButtonCopied": "Copied!", - "expressiveCode.copyButtonTooltip": "Copy to clipboard", - "expressiveCode.terminalWindowFallbackTitle": "Terminal window", - - "pagefind.clear_search": "Clear", - "pagefind.load_more": "Load more results", - "pagefind.search_label": "Search this site", - "pagefind.filters_label": "Filters", - "pagefind.zero_results": "No results for [SEARCH_TERM]", - "pagefind.many_results": "[COUNT] results for [SEARCH_TERM]", - "pagefind.one_result": "[COUNT] result for [SEARCH_TERM]", - "pagefind.alt_search": "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead", - "pagefind.search_suggestion": "No results for [SEARCH_TERM]. Try one of the following searches:", - "pagefind.searching": "Searching for [SEARCH_TERM]..." + "footer.builtBy": "This site was designed and developed by", + "page.translateLink": "Translate this page", + "download.windows.headline": "Download for Windows", + "download.windows.note": "Requires Windows 10 or later, and NVIDIA or AMD GPU.", + "download.windows.action": "Download EXE", + "download.macos.headline": "Download for macOS", + "download.macos.note": "Requires Apple Silicon (M-Series). Not compatible with Intel.", + "download.macos.action": "Download DMG", + "download.linux.headline": "Download for Linux", + "download.linux.note": "Requires NVIDIA or AMD GPU. Compatible with most distributions.", + "download.linux.action": "Download AppImage", + "download.github.headline": "Download from GitHub", + "download.github.description": "For advanced users who want to set up Invoke manually or contribute to the project.", + "download.docker.headline": "Run with Docker", + "download.docker.description": "For users who want to run Invoke without installing dependencies directly on their system.", + "download.hosted.heading": "Hosted Options", + "download.hosted.description": "For users who want to run Invoke on a hosted GPU service instead of their own hardware.", + "download.aibadgr.headline": "Run on AI Badgr", + "download.aibadgr.description": "Run on the AI Badgr hosted GPU service.", + "download.runpod.headline": "Run on RunPod", + "download.runpod.description": "Run on the RunPod hosted GPU service.", + "download.railway.headline": "Run on Railway", + "download.railway.description": "Run on the Railway hosted GPU service.", + "download.separator": "OR", + "systemRequirements.title": "System Requirements", + "systemRequirements.description": "Please check the system requirements page to make sure your hardware is capable of running the desired models.", + "settings.type": "Type", + "settings.default": "Default", + "settings.environment": "Env", + "settings.values": "Values:" } diff --git a/docs/src/lib/base-path.ts b/docs/src/lib/base-path.ts index 23042d899a5..8e80ab7f70c 100644 --- a/docs/src/lib/base-path.ts +++ b/docs/src/lib/base-path.ts @@ -4,3 +4,12 @@ export const withBase = (path: string, baseUrl: string) => { return `${normalizedBase}${normalizedPath}`; }; + +export const localizePath = (path: string, locale?: string) => { + if (!locale) { + return path; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `/${locale}${normalizedPath}`; +}; diff --git a/docs/src/lib/components/DownloadOptions.astro b/docs/src/lib/components/DownloadOptions.astro index 95124322fd9..51a73e4d28b 100644 --- a/docs/src/lib/components/DownloadOptions.astro +++ b/docs/src/lib/components/DownloadOptions.astro @@ -1,7 +1,16 @@ --- import { CardGrid, LinkCard, Icon, LinkButton } from '@astrojs/starlight/components'; import { type StarlightIcon } from '@astrojs/starlight/types'; -import { withBase } from '../base-path'; +import { localizePath, withBase } from '../base-path'; + +const t = Astro.locals.t; +let locale: string | undefined; +try { + locale = Astro.locals.starlightRoute.locale; +} catch { + // Content is also rendered outside a Starlight route when generating llms-full.txt. + locale = undefined; +} type LauncherDownloadOption = { icon: StarlightIcon; @@ -13,59 +22,59 @@ type LauncherDownloadOption = { const launcherDownloadOptions: Record = { windows: { icon: 'seti:windows', - headline: 'Download for Windows', - note: 'Requires Windows 10 or later, and NVIDIA or AMD GPU.', + headline: t('download.windows.headline'), + note: t('download.windows.note'), launcherDownloadLink: 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition.Setup.latest.exe', - launcherDownloadLabel: 'Download EXE', + launcherDownloadLabel: t('download.windows.action'), }, macos: { icon: 'apple', - headline: 'Download for MacOS', - note: 'Requires Apple Silicon (M-Series). Not compatible with Intel.', + headline: t('download.macos.headline'), + note: t('download.macos.note'), launcherDownloadLink: 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest-arm64.dmg', - launcherDownloadLabel: 'Download DMG', + launcherDownloadLabel: t('download.macos.action'), }, linux: { icon: 'linux', - headline: 'Download for Linux', - note: 'Requires NVIDIA or AMD GPU. Compatible with most distributions.', + headline: t('download.linux.headline'), + note: t('download.linux.note'), launcherDownloadLink: 'https://github.com/invoke-ai/launcher/releases/latest/download/Invoke.Community.Edition-latest.AppImage', - launcherDownloadLabel: 'Download AppImage', + launcherDownloadLabel: t('download.linux.action'), }, }; const manualDownloadOptions = { github: { - headline: 'Download from GitHub', - description: 'For advanced users who want to set up Invoke manually or contribute to the project.', + headline: t('download.github.headline'), + description: t('download.github.description'), href: 'https://github.com/invoke-ai/InvokeAI/releases', }, docker: { - headline: 'Run with Docker', - description: 'For users who want to run Invoke without installing dependencies directly on their system.', - href: withBase('/configuration/docker/', import.meta.env.BASE_URL), + headline: t('download.docker.headline'), + description: t('download.docker.description'), + href: withBase(localizePath('/configuration/docker/', locale), import.meta.env.BASE_URL), }, }; const hostedOptions = { aibadgr: { - headline: 'Run on AI Badgr', - description: 'Run on the AI Badgr hosted GPU service', - href: 'https://aibadgr.com/gpu/launch?template=invokeai', - }, + headline: t('download.aibadgr.headline'), + description: t('download.aibadgr.description'), + href: 'https://aibadgr.com/gpu/launch?template=invokeai', + }, runpod: { - headline: 'Run on RunPod', - description: 'Run on the RunPod hosted GPU service', - href: 'https://www.runpod.io/blog/invoke-ai-stable-diffusion-runpod-nfz18', - }, + headline: t('download.runpod.headline'), + description: t('download.runpod.description'), + href: 'https://www.runpod.io/blog/invoke-ai-stable-diffusion-runpod-nfz18', + }, railway: { - headline: 'Run on Railway', - description: 'Run on the Railway hosted GPU service', - href: 'https://railway.com/deploy/invokeai', - }, + headline: t('download.railway.headline'), + description: t('download.railway.description'), + href: 'https://railway.com/deploy/invokeai', + }, }; --- @@ -99,7 +108,7 @@ const hostedOptions = {
- OR + {t('download.separator')}
@@ -113,8 +122,8 @@ const hostedOptions = {
-

Hosted Options

-

For users who want to run Invoke on a hosted GPU service instead of their own hardware.

+

{t('download.hosted.heading')}

+

{t('download.hosted.description')}

{ diff --git a/docs/src/lib/components/EditLink.astro b/docs/src/lib/components/EditLink.astro new file mode 100644 index 00000000000..51e89b548f5 --- /dev/null +++ b/docs/src/lib/components/EditLink.astro @@ -0,0 +1,32 @@ +--- +import { Icon } from '@astrojs/starlight/components'; + +const crowdinProjectUrl = 'https://crowdin.com/project/invokeai-docs'; +const { editUrl, locale } = Astro.locals.starlightRoute; +const href = locale ? crowdinProjectUrl : editUrl; +const label = locale ? Astro.locals.t('page.translateLink') : Astro.locals.t('page.editLink'); +--- + +{ + href && ( + + + {label} + + ) +} + + diff --git a/docs/src/lib/components/Footer.astro b/docs/src/lib/components/Footer.astro index 65137dec3b9..00c21017db4 100644 --- a/docs/src/lib/components/Footer.astro +++ b/docs/src/lib/components/Footer.astro @@ -5,7 +5,7 @@ import PageFooter from '@astrojs/starlight/components/Footer.astro';
- This site was designed and developed by Aether Fox Studio
+ +{ + locale && ( + + ) +} diff --git a/docs/src/lib/components/SettingsDocs.astro b/docs/src/lib/components/SettingsDocs.astro index c539b27cd46..e1c9e2c9d6e 100644 --- a/docs/src/lib/components/SettingsDocs.astro +++ b/docs/src/lib/components/SettingsDocs.astro @@ -1,6 +1,8 @@ --- import settingsData from '../../generated/settings.json'; +const t = Astro.locals.t; + const groupedSettings = Object.entries( settingsData.settings.reduce((groups, setting) => { const category = setting.category || 'OTHER'; @@ -62,15 +64,15 @@ const formatCategoryName = (category) => {
- Type + {t('settings.type')} {formatType(setting.type)}
- Default + {t('settings.default')} {formatValue(setting.default)}
- Env + {t('settings.environment')} {setting.env_var}
@@ -78,7 +80,7 @@ const formatCategoryName = (category) => { {setting.description} {setting.literal_values.length > 0 && ( - Values: {setting.literal_values.map((v) => {formatValue(v)}).reduce((prev, curr) => [prev, ' ', curr])} + {t('settings.values')} {setting.literal_values.map((v) => {formatValue(v)}).reduce((prev, curr) => [prev, ' ', curr])} )} {Object.keys(setting.validation).length > 0 && ( diff --git a/docs/src/lib/components/SystemRequirmentsLink.astro b/docs/src/lib/components/SystemRequirmentsLink.astro index 16455b8e749..64821d35862 100644 --- a/docs/src/lib/components/SystemRequirmentsLink.astro +++ b/docs/src/lib/components/SystemRequirmentsLink.astro @@ -1,10 +1,18 @@ --- import { LinkCard } from '@astrojs/starlight/components'; -import { withBase } from '../base-path'; +import { localizePath, withBase } from '../base-path'; + +let locale: string | undefined; +try { + locale = Astro.locals.starlightRoute.locale; +} catch { + // Content is also rendered outside a Starlight route when generating llms-full.txt. + locale = undefined; +} --- From 2e8f9c57352342ec6851dfab2c92568ebeb40aec Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 20 Jul 2026 16:22:55 -0600 Subject: [PATCH 2/7] fix(docs): update crowdin project urls --- docs/scripts/verify-deploy-output.mjs | 2 +- docs/src/content/docs/contributing/translations.md | 4 ++-- docs/src/content/docs/development/Documentation/index.mdx | 2 +- docs/src/lib/components/EditLink.astro | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/scripts/verify-deploy-output.mjs b/docs/scripts/verify-deploy-output.mjs index 037646a07e7..d99b2a8de4d 100644 --- a/docs/scripts/verify-deploy-output.mjs +++ b/docs/scripts/verify-deploy-output.mjs @@ -55,7 +55,7 @@ for (const locale of ['de', 'es', 'hi']) { 'lang="en" dir="ltr"', `hreflang="${locale}" href="${siteUrl(`/${locale}/start-here/installation/`)}`, `href="${withBase(`/${locale}/start-here/system-requirements/`)}`, - 'href="https://crowdin.com/project/invokeai-docs"', + 'href="https://crowdin.com/project/invoke"', 'data-pagefind-body', ], excludes: [ diff --git a/docs/src/content/docs/contributing/translations.md b/docs/src/content/docs/contributing/translations.md index bac7d5551c9..6773cd627e9 100644 --- a/docs/src/content/docs/contributing/translations.md +++ b/docs/src/content/docs/contributing/translations.md @@ -4,14 +4,14 @@ title: Translations InvokeAI uses two translation services: -- [Crowdin](https://crowdin.com/project/invokeai-docs) for this documentation site. +- [Crowdin](https://crowdin.com/project/invoke) for this documentation site. - [Weblate](https://hosted.weblate.org/engage/invokeai/) for text in the InvokeAI application. The initial documentation languages are German, Spanish, and Hindi. Pages that are not translated yet display the English content with an untranslated-content notice. Partially translated pages may retain some English text. ## Translating the documentation -Visit the [InvokeAI Docs project on Crowdin](https://crowdin.com/project/invokeai-docs), sign in, and select German, Spanish, or Hindi. Crowdin preserves Markdown structure, code examples, links, and other non-translatable syntax while presenting the page text for translation. +Visit the [InvokeAI Docs project on Crowdin](https://crowdin.com/project/invoke), sign in, and select German, Spanish, or Hindi. Crowdin preserves Markdown structure, code examples, links, and other non-translatable syntax while presenting the page text for translation. English documentation is maintained in GitHub and synchronized to Crowdin. Completed translations are returned in an automated pull request, checked by the normal documentation build, and merged by an InvokeAI maintainer. Do not edit files inside the `docs/src/content/docs/de`, `es`, or `hi` directories directly because Crowdin owns those generated files. diff --git a/docs/src/content/docs/development/Documentation/index.mdx b/docs/src/content/docs/development/Documentation/index.mdx index 942e897e974..491c5b0deae 100644 --- a/docs/src/content/docs/development/Documentation/index.mdx +++ b/docs/src/content/docs/development/Documentation/index.mdx @@ -198,7 +198,7 @@ To render the image, you'd just use a relative path in your markdown. ### Adding Translations -Documentation translations are managed in the [InvokeAI Docs Crowdin project](https://crowdin.com/project/invokeai-docs). English Markdown and MDX files in `src/content/docs` are the source of truth. Crowdin exports German, Spanish, and Hindi pages into locale directories under that same collection, matching [Starlight's internationalized content layout](https://starlight.astro.build/guides/i18n/). +Documentation translations are managed in the [InvokeAI Docs Crowdin project](https://crowdin.com/project/invoke). English Markdown and MDX files in `src/content/docs` are the source of truth. Crowdin exports German, Spanish, and Hindi pages into locale directories under that same collection, matching [Starlight's internationalized content layout](https://starlight.astro.build/guides/i18n/). Do not manually edit exported locale files. Make English documentation changes in GitHub and translation changes in Crowdin. Crowdin maintains a translation pull request, and the documentation workflow builds every locale before the PR is merged. diff --git a/docs/src/lib/components/EditLink.astro b/docs/src/lib/components/EditLink.astro index 51e89b548f5..943f70b60e1 100644 --- a/docs/src/lib/components/EditLink.astro +++ b/docs/src/lib/components/EditLink.astro @@ -1,7 +1,7 @@ --- import { Icon } from '@astrojs/starlight/components'; -const crowdinProjectUrl = 'https://crowdin.com/project/invokeai-docs'; +const crowdinProjectUrl = 'https://crowdin.com/project/invoke'; const { editUrl, locale } = Astro.locals.starlightRoute; const href = locale ? crowdinProjectUrl : editUrl; const label = locale ? Astro.locals.t('page.translateLink') : Astro.locals.t('page.editLink'); From 19a1f91f1eedef225179c4f866649b58a5a0f23f Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 27 Jul 2026 00:54:05 -0600 Subject: [PATCH 3/7] refactor(docs): extract shared locale path helpers The rule for prefixing a root-relative path with a locale, and the list of custom pages exempt from it, were hand-copied between the remark plugin and the runtime rewrite in MarkdownContent. Move both into src/lib/link-localization.mjs so a new page under src/pages/ only has to be registered once, and cover the helpers with unit tests. Claude-Session: https://claude.ai/code/session_016pdfTjHiM9inq24jMHDE2y --- docs/package.json | 2 +- docs/plugins/remark-localize-content.mjs | 24 +++------------ docs/src/lib/link-localization.mjs | 30 +++++++++++++++++++ docs/src/lib/link-localization.test.mjs | 38 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 21 deletions(-) create mode 100644 docs/src/lib/link-localization.mjs create mode 100644 docs/src/lib/link-localization.test.mjs diff --git a/docs/package.json b/docs/package.json index 23f700f22e5..f69d70fe462 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,7 +9,7 @@ "check-docs-data": "pnpm run generate-docs-data && git diff --exit-code -- src/generated", "check-deploy-output": "node ./scripts/verify-deploy-output.mjs", "check-redirects": "node ./scripts/validate-redirect-targets.mjs", - "test:localization": "node --test ./plugins/remark-localize-content.test.mjs", + "test:localization": "node --test \"plugins/**/*.test.mjs\" \"src/lib/**/*.test.mjs\"", "build": "astro build", "preview": "astro preview", "astro": "astro" diff --git a/docs/plugins/remark-localize-content.mjs b/docs/plugins/remark-localize-content.mjs index 86f22516963..82865462752 100644 --- a/docs/plugins/remark-localize-content.mjs +++ b/docs/plugins/remark-localize-content.mjs @@ -1,6 +1,8 @@ import { existsSync } from 'node:fs'; import { dirname, relative, resolve, sep } from 'node:path'; +import { localizeRootPath } from '../src/lib/link-localization.mjs'; + const relativeImportPattern = /(from\s+|import\s*)(['"])(\.\.?\/[^'"]+)\2/g; export function remarkLocalizeContent(options = {}) { @@ -15,7 +17,7 @@ export function remarkLocalizeContent(options = {}) { walk(tree, (node) => { if (node.type === 'link' && typeof node.url === 'string') { - node.url = localizeRootLink(node.url, context.locale); + node.url = localizeRootPath(node.url, context.locale); } if (node.type === 'image' && typeof node.url === 'string') { @@ -31,7 +33,7 @@ export function remarkLocalizeContent(options = {}) { if (node.type === 'mdxJsxAttribute' && typeof node.value === 'string') { if (node.name === 'href' || node.name === 'link') { - node.value = localizeRootLink(node.value, context.locale); + node.value = localizeRootPath(node.value, context.locale); } else if (node.name === 'src') { node.value = pointToSourceAsset(node.value, context); } @@ -68,23 +70,6 @@ function getLocalizedFileContext(filePath, locales) { }; } -function localizeRootLink(url, locale) { - if (!url.startsWith('/') || url.startsWith('//')) { - return url; - } - - if ( - url === `/${locale}` || - url.startsWith(`/${locale}/`) || - url === '/download' || - url.startsWith('/download/') - ) { - return url; - } - - return `/${locale}${url}`; -} - function pointToSourceAsset(url, context) { if (!url.startsWith('./') && !url.startsWith('../')) { return url; @@ -122,6 +107,5 @@ function walk(node, visitor) { export const testing = { getLocalizedFileContext, - localizeRootLink, pointToSourceAsset, }; diff --git a/docs/src/lib/link-localization.mjs b/docs/src/lib/link-localization.mjs new file mode 100644 index 00000000000..f9e39e352ca --- /dev/null +++ b/docs/src/lib/link-localization.mjs @@ -0,0 +1,30 @@ +/** + * Root-relative paths that must never be prefixed with a locale. + * + * These are custom pages in `src/pages/` rather than docs-collection entries, so Starlight's + * locale fallback never generates a `//…` route for them and prefixing would 404. + * Add an entry here whenever a new page lands in `src/pages/`. + * + * This module is the single source of truth for the two places that localize root paths: + * the build-time `remark-localize-content` plugin and the runtime rewrite in + * `components/MarkdownContent.astro`. + */ +export const LOCALE_EXEMPT_PATHS = ['/download']; + +/** True when `path` is a root-relative docs path that should be prefixed with `locale`. */ +export function shouldLocalizeRootPath(path, locale) { + if (!path.startsWith('/') || path.startsWith('//')) { + return false; + } + + if (path === `/${locale}` || path.startsWith(`/${locale}/`)) { + return false; + } + + return !LOCALE_EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(`${exempt}/`)); +} + +/** Prefixes `path` with `locale` when it points at a localizable docs route. */ +export function localizeRootPath(path, locale) { + return shouldLocalizeRootPath(path, locale) ? `/${locale}${path}` : path; +} diff --git a/docs/src/lib/link-localization.test.mjs b/docs/src/lib/link-localization.test.mjs new file mode 100644 index 00000000000..d0117d9219f --- /dev/null +++ b/docs/src/lib/link-localization.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { LOCALE_EXEMPT_PATHS, localizeRootPath, shouldLocalizeRootPath } from './link-localization.mjs'; + +test('prefixes root-relative docs paths with the locale', () => { + assert.equal(localizeRootPath('/concepts/prompting-guide/', 'es'), '/es/concepts/prompting-guide/'); + assert.equal(localizeRootPath('/', 'es'), '/es/'); +}); + +test('leaves paths that are not root-relative alone', () => { + assert.equal(localizeRootPath('https://example.com/', 'es'), 'https://example.com/'); + assert.equal(localizeRootPath('//cdn.example.com/x.js', 'es'), '//cdn.example.com/x.js'); + assert.equal(localizeRootPath('#section', 'es'), '#section'); + assert.equal(localizeRootPath('./assets/gallery.png', 'es'), './assets/gallery.png'); +}); + +test('does not double-prefix an already localized path', () => { + assert.equal(localizeRootPath('/es', 'es'), '/es'); + assert.equal(localizeRootPath('/es/features/gallery/', 'es'), '/es/features/gallery/'); + // A different locale's path is still a valid target and must not be re-prefixed twice. + assert.equal(localizeRootPath('/de/features/gallery/', 'de'), '/de/features/gallery/'); +}); + +test('never localizes custom pages that have no per-locale route', () => { + for (const exempt of LOCALE_EXEMPT_PATHS) { + assert.equal(shouldLocalizeRootPath(exempt, 'hi'), false); + assert.equal(shouldLocalizeRootPath(`${exempt}/`, 'hi'), false); + assert.equal(localizeRootPath(exempt, 'hi'), exempt); + } +}); + +test('does not treat a path that merely starts with an exempt path as exempt', () => { + // `/downloads/` is a docs route, not the `/download` custom page. + assert.equal(localizeRootPath('/downloads/', 'hi'), '/hi/downloads/'); + // Same for a locale-like prefix that is not the locale segment. + assert.equal(localizeRootPath('/development/guides/', 'de'), '/de/development/guides/'); +}); From 2c4c8a9d620b6c2cfb1b9e46a27ece542a006447 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 27 Jul 2026 00:54:14 -0600 Subject: [PATCH 4/7] fix(docs): apply base path to root-relative links in MDX components MDX components stay JSX nodes through rehype instead of becoming elements, so rehypePrefixBaseToRootLinks skipped them and Starlight's LinkButton passes href straight to the DOM. The home page "Get Invoke" button therefore shipped as /download/ on the GitHub Pages target and 404'd. Rewrite href/link attributes on MDX JSX nodes too, derive the links-validator exclusions from the shared exemption list so they stay base-aware, and assert the button in the deploy output check. Claude-Session: https://claude.ai/code/session_016pdfTjHiM9inq24jMHDE2y --- docs/astro.config.mjs | 7 +- .../rehype-prefix-base-to-root-links.mjs | 44 ++++++-- .../rehype-prefix-base-to-root-links.test.mjs | 106 ++++++++++++++++++ docs/scripts/verify-deploy-output.mjs | 8 +- 4 files changed, 150 insertions(+), 15 deletions(-) create mode 100644 docs/plugins/rehype-prefix-base-to-root-links.test.mjs diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 1984590b58a..574c62e368c 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -8,6 +8,7 @@ import starlightLlmsText from 'starlight-llms-txt'; import starlightChangelogs from 'starlight-changelogs'; import { rehypePrefixBaseToRootLinks } from './plugins/rehype-prefix-base-to-root-links.mjs'; import { remarkLocalizeContent } from './plugins/remark-localize-content.mjs'; +import { LOCALE_EXEMPT_PATHS } from './src/lib/link-localization.mjs'; import starlightContextualMenu from 'starlight-contextual-menu'; // Configs @@ -98,8 +99,10 @@ export default defineConfig({ starlightLinksValidator({ errorOnRelativeLinks: false, errorOnLocalLinks: false, - // The validator only knows content collection routes, not custom Astro pages. - exclude: ['/download/'], + // The validator only knows content collection routes, not the custom Astro pages in + // src/pages/. Derived from the shared exemption list so a new custom page only has to + // be registered in one place, and base-aware because links are emitted with the base. + exclude: LOCALE_EXEMPT_PATHS.flatMap((path) => [`${base}${path}`, `${base}${path}/`]), }), starlightLlmsText(), starlightChangelogs(), diff --git a/docs/plugins/rehype-prefix-base-to-root-links.mjs b/docs/plugins/rehype-prefix-base-to-root-links.mjs index fa424ab5c54..ac6a63469f8 100644 --- a/docs/plugins/rehype-prefix-base-to-root-links.mjs +++ b/docs/plugins/rehype-prefix-base-to-root-links.mjs @@ -7,33 +7,53 @@ export function rehypePrefixBaseToRootLinks(options = {}) { } walk(tree, (node) => { - if (node.tagName !== 'a') { - return; - } + if (node.type === 'element') { + if (node.tagName !== 'a') { + return; + } - const href = node.properties?.href; + const href = node.properties?.href; - if (typeof href !== 'string') { - return; - } + if (typeof href === 'string') { + node.properties.href = prefixBase(href, base); + } - if (!href.startsWith('/') || href.startsWith('//') || href.startsWith(`${base}/`)) { return; } - node.properties.href = `${base}${href}`; + // MDX components such as Starlight's / stay JSX nodes all the way + // through rehype rather than becoming `` elements, and they pass `href` straight to the + // DOM without applying the base. Without this branch, a root-relative href written in MDX + // ships unprefixed and 404s on the GitHub Pages target. + if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { + for (const attribute of node.attributes ?? []) { + if (attribute?.type !== 'mdxJsxAttribute' || typeof attribute.value !== 'string') { + continue; + } + + if (attribute.name === 'href' || attribute.name === 'link') { + attribute.value = prefixBase(attribute.value, base); + } + } + } }); }; } +function prefixBase(href, base) { + if (!href.startsWith('/') || href.startsWith('//') || href === base || href.startsWith(`${base}/`)) { + return href; + } + + return `${base}${href}`; +} + function walk(node, visitor) { if (!node || typeof node !== 'object') { return; } - if (node.type === 'element') { - visitor(node); - } + visitor(node); if (!Array.isArray(node.children)) { return; diff --git a/docs/plugins/rehype-prefix-base-to-root-links.test.mjs b/docs/plugins/rehype-prefix-base-to-root-links.test.mjs new file mode 100644 index 00000000000..93542d34d7c --- /dev/null +++ b/docs/plugins/rehype-prefix-base-to-root-links.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { rehypePrefixBaseToRootLinks } from './rehype-prefix-base-to-root-links.mjs'; + +const anchor = (href) => ({ type: 'element', tagName: 'a', properties: { href }, children: [] }); + +const jsxElement = (name, attributes) => ({ + type: 'mdxJsxFlowElement', + name, + attributes: attributes.map(([attrName, value]) => ({ type: 'mdxJsxAttribute', name: attrName, value })), + children: [], +}); + +test('prefixes the base onto root-relative anchor hrefs', () => { + const tree = { + type: 'root', + children: [ + anchor('/start-here/installation/'), + anchor('/InvokeAI/concepts/'), + anchor('https://example.com/'), + anchor('#section'), + anchor('//cdn.example.com/asset.js'), + ], + }; + + rehypePrefixBaseToRootLinks({ base: '/InvokeAI' })(tree); + + assert.equal(tree.children[0].properties.href, '/InvokeAI/start-here/installation/'); + assert.equal(tree.children[1].properties.href, '/InvokeAI/concepts/'); + assert.equal(tree.children[2].properties.href, 'https://example.com/'); + assert.equal(tree.children[3].properties.href, '#section'); + assert.equal(tree.children[4].properties.href, '//cdn.example.com/asset.js'); +}); + +test('prefixes the base onto MDX component href and link attributes', () => { + const tree = { + type: 'root', + children: [ + jsxElement('LinkButton', [['href', '/download/']]), + jsxElement('LinkCard', [['link', '/de/features/gallery/']]), + jsxElement('LinkCard', [['href', '/InvokeAI/troubleshooting/faq/']]), + jsxElement('Image', [['src', '/assets/splash.png']]), + ], + }; + + rehypePrefixBaseToRootLinks({ base: '/InvokeAI' })(tree); + + assert.equal(tree.children[0].attributes[0].value, '/InvokeAI/download/'); + assert.equal(tree.children[1].attributes[0].value, '/InvokeAI/de/features/gallery/'); + // Already based — must not double up. + assert.equal(tree.children[2].attributes[0].value, '/InvokeAI/troubleshooting/faq/'); + // Only href/link are link attributes; src is left to Astro's asset pipeline. + assert.equal(tree.children[3].attributes[0].value, '/assets/splash.png'); +}); + +test('leaves expression-valued attributes alone', () => { + // `href={withBase('/download/', BASE_URL)}` parses to an expression, not a string, and the + // component already applies the base itself. + const expressionAttribute = { + type: 'mdxJsxAttribute', + name: 'href', + value: { type: 'mdxJsxAttributeValueExpression', value: "withBase('/download/')" }, + }; + const tree = { + type: 'root', + children: [{ type: 'mdxJsxFlowElement', name: 'LinkButton', attributes: [expressionAttribute], children: [] }], + }; + + rehypePrefixBaseToRootLinks({ base: '/InvokeAI' })(tree); + + assert.deepEqual(tree.children[0].attributes[0].value, { + type: 'mdxJsxAttributeValueExpression', + value: "withBase('/download/')", + }); +}); + +test('is a no-op when no base is configured', () => { + const tree = { + type: 'root', + children: [anchor('/start-here/installation/'), jsxElement('LinkButton', [['href', '/download/']])], + }; + + rehypePrefixBaseToRootLinks({ base: '/' })(tree); + + assert.equal(tree.children[0].properties.href, '/start-here/installation/'); + assert.equal(tree.children[1].attributes[0].value, '/download/'); +}); + +test('rewrites links nested inside MDX components', () => { + const tree = { + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'Card', + attributes: [], + children: [anchor('/concepts/prompting-guide/')], + }, + ], + }; + + rehypePrefixBaseToRootLinks({ base: '/InvokeAI' })(tree); + + assert.equal(tree.children[0].children[0].properties.href, '/InvokeAI/concepts/prompting-guide/'); +}); diff --git a/docs/scripts/verify-deploy-output.mjs b/docs/scripts/verify-deploy-output.mjs index d99b2a8de4d..78bf57204a0 100644 --- a/docs/scripts/verify-deploy-output.mjs +++ b/docs/scripts/verify-deploy-output.mjs @@ -13,8 +13,14 @@ const expectations = [ `href="${withBase('/_astro/')}`, `src="${withBase('/_astro/')}`, `href="${withBase('/start-here/installation/')}`, + // The "Get Invoke" button is a root-relative href on an MDX component rather than a plain + // markdown link, which is an easy way to lose the base prefix. + `href="${withBase('/download/')}"`, ], - excludes: deployTarget === 'custom' ? ['href="/InvokeAI/', 'src="/InvokeAI/'] : ['href="/_astro/', 'src="/_astro/'], + excludes: + deployTarget === 'custom' + ? ['href="/InvokeAI/', 'src="/InvokeAI/'] + : ['href="/_astro/', 'src="/_astro/', 'href="/download/"'], }, { file: 'contributing/index.html', From 98e30f16690305dac0f3f729855cdfe055a31938 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Mon, 27 Jul 2026 00:54:21 -0600 Subject: [PATCH 5/7] fix(docs): correct base stripping in the localized link rewrite An href equal to the base with no trailing slash did not match the `${base}/` test, so it fell through unstripped and the locale was inserted after it, producing /InvokeAI/de/InvokeAI. Handle the exact-base case, adopt the shared exemption list, and document why this rewrite happens at runtime and what that costs. Claude-Session: https://claude.ai/code/session_016pdfTjHiM9inq24jMHDE2y --- docs/src/lib/components/MarkdownContent.astro | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/src/lib/components/MarkdownContent.astro b/docs/src/lib/components/MarkdownContent.astro index 32e4c6b9a96..3bf89150910 100644 --- a/docs/src/lib/components/MarkdownContent.astro +++ b/docs/src/lib/components/MarkdownContent.astro @@ -1,5 +1,21 @@ --- import '@astrojs/starlight/style/markdown.css'; +import { LOCALE_EXEMPT_PATHS } from '../link-localization.mjs'; + +/* + * A localized route serves one of two things: a Crowdin translation, whose in-content links the + * `remark-localize-content` plugin already prefixed at build time, or — until that translation + * lands — the English source as Starlight fallback content. Fallback routes reuse the compiled + * English module verbatim, so their links cannot be rewritten at build time and are patched here + * at runtime instead. + * + * The tradeoff is deliberate: the static HTML of a fallback page still carries English URLs, so + * crawlers and visitors without JS follow links out of the locale. + * + * `define:vars` implies `is:inline`, which keeps this script running at parse time instead of + * being deferred as a bundled module. Inline scripts cannot import, so the exemption list is + * handed in from `link-localization.mjs` — that list is the part that drifts, not the loop below. + */ const locale = Astro.locals.starlightRoute.locale; const base = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL.replace(/\/$/, ''); @@ -9,7 +25,7 @@ const base = import.meta.env.BASE_URL === '/' ? '' : import.meta.env.BASE_URL.re { locale && ( -