Skip to content
Closed
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
28 changes: 28 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ 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 { LOCALE_EXEMPT_PATHS } from './src/lib/link-localization.mjs';
import starlightContextualMenu from 'starlight-contextual-menu';

// Configs
Expand Down Expand Up @@ -34,13 +36,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 +64,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 +91,22 @@ 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,
// Only part of the docs tree is translated, and pages within it land one Crowdin batch
// at a time, so a localized page linking to a page that only exists as Starlight
// fallback content is the normal case rather than a broken link.
errorOnFallbackPages: false,
// 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(),
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/**/*.test.mjs\" \"src/lib/**/*.test.mjs\"",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
Expand Down
44 changes: 32 additions & 12 deletions docs/plugins/rehype-prefix-base-to-root-links.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <LinkButton>/<LinkCard> stay JSX nodes all the way
// through rehype rather than becoming `<a>` 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;
Expand Down
106 changes: 106 additions & 0 deletions docs/plugins/rehype-prefix-base-to-root-links.test.mjs
Original file line number Diff line number Diff line change
@@ -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/');
});
Loading
Loading