Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
filters: |
docs:
- '.github/workflows/deploy-docs.yml'
- 'crowdin.yml'
- 'docs/**'
- 'scripts/generate_docs_json.py'
- 'invokeai/app/**'
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions crowdin.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,13 +35,17 @@ export default defineConfig({
site,
base: base || undefined,
markdown: {
remarkPlugins: [[remarkLocalizeContent, { locales: ['de', 'es', 'hi'] }]],
rehypePlugins: [[rehypePrefixBaseToRootLinks, { base }]],
},
integrations: [
starlight({
// Content
title: {
en: 'InvokeAI Documentation',
de: 'InvokeAI-Dokumentation',
es: 'Documentación de InvokeAI',
hi: 'InvokeAI दस्तावेज़',
},
logo: {
src: './src/assets/invoke-icon-wide.svg',
Expand All @@ -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: {
Expand All @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
127 changes: 127 additions & 0 deletions docs/plugins/remark-localize-content.mjs
Original file line number Diff line number Diff line change
@@ -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,
};
74 changes: 74 additions & 0 deletions docs/plugins/remark-localize-content.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
20 changes: 20 additions & 0 deletions docs/scripts/verify-deploy-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -45,6 +47,24 @@ const expectations = [
},
];

for (const locale of ['de', 'es', 'hi']) {
expectations.push({
file: `${locale}/start-here/installation/index.html`,
includes: [
`<html lang="${locale}"`,
'lang="en" dir="ltr"',
`hreflang="${locale}" href="${siteUrl(`/${locale}/start-here/installation/`)}`,
`href="${withBase(`/${locale}/start-here/system-requirements/`)}`,
'href="https://crowdin.com/project/invoke"',
'data-pagefind-body',
],
excludes: [
`href="${withBase(`/${locale}/${locale}/`)}`,
`href="${withBase('/start-here/system-requirements/')}"`,
],
});
}

const errors = [];

for (const { file, includes = [], excludes = [] } of expectations) {
Expand Down
Loading
Loading