diff --git a/.eslintignore b/.eslintignore index 49cbc607f67a..c2a430de7e39 100644 --- a/.eslintignore +++ b/.eslintignore @@ -25,6 +25,7 @@ packages/docusaurus-plugin-ideal-image/lib/ packages/docusaurus-plugin-ideal-image/copyUntypedFiles.js packages/docusaurus-theme-common/lib/ packages/docusaurus-theme-classic/lib/ +packages/docusaurus-theme-classic/lib-next/ packages/docusaurus-theme-bootstrap/lib/ packages/docusaurus-migrate/lib/ diff --git a/.gitignore b/.gitignore index 08f8d64c8daa..bb074450ed4c 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,16 @@ packages/docusaurus-plugin-sitemap/lib/ packages/docusaurus-plugin-ideal-image/lib/ packages/docusaurus-theme-common/lib/ packages/docusaurus-theme-classic/lib/ +packages/docusaurus-theme-classic/lib-next/ packages/docusaurus-theme-bootstrap/lib/ packages/docusaurus-migrate/lib/ -website/netlifyDeployPreview +website/netlifyDeployPreview/* !website/netlifyDeployPreview/index.html !website/netlifyDeployPreview/_redirects website-1.x-migrated + +website/i18n/**/* +#!website/i18n/fr +#!website/i18n/fr/**/* diff --git a/crowdin-v2.yaml b/crowdin-v2.yaml new file mode 100644 index 000000000000..59a35c5039a3 --- /dev/null +++ b/crowdin-v2.yaml @@ -0,0 +1,155 @@ +# +# Your Crowdin credentials +# +'project_id': '416738' +'api_token_env': 'CROWDIN_PERSONAL_TOKEN' +'base_path': '.' +'base_url': 'https://api.crowdin.com' + +# +# Choose file structure in Crowdin +# e.g. true or false +# +'preserve_hierarchy': true + +# +# Files configuration +# +files: + [ + { + 'source': '/website/i18n/en/**/*', + 'translation': '/website/i18n/%two_letters_code%/**/%original_file_name%', + 'ignore': ['/**/.DS_Store'], + }, + { + 'source': '/website/docs/**/*', + 'translation': '/website/i18n/%two_letters_code%/docusaurus-plugin-content-docs/current/**/%original_file_name%', + 'ignore': ['/**/.DS_Store'], + }, + { + 'source': '/website/community/**/*', + 'translation': '/website/i18n/%two_letters_code%/docusaurus-plugin-content-docs-community/current/**/%original_file_name%', + 'ignore': ['/**/.DS_Store'], + }, + { + 'source': '/website/versioned_docs/**/*', + 'translation': '/website/i18n/%two_letters_code%/docusaurus-plugin-content-docs/**/%original_file_name%', + 'ignore': ['/**/.DS_Store'], + }, + { + 'source': '/website-1.x/blog/**/*', + 'translation': '/website/i18n/%two_letters_code%/docusaurus-plugin-content-blog/**/%original_file_name%', + 'ignore': ['/**/.DS_Store'], + }, + { + 'source': '/website/src/pages/**/*', + 'translation': '/website/i18n/%two_letters_code%/docusaurus-plugin-content-pages/**/%original_file_name%', + 'ignore': + [ + '/**/*.js', + '/**/*.jsx', + '/**/*.ts', + '/**/*.tsx', + '/**/*.css', + '/**/.DS_Store', + ], + }, + ] +# +# Source files filter +# e.g. "/resources/en/*.json" +# +#"source" : "/website/docs/**/*.md", +# +# Where translations will be placed +# e.g. "/resources/docs/%two_letters_code%/%original_file_name%" +# +#"translation" : "/website/i18n/%language%/docs/current/%original_file_name%", +# +# Files or directories for ignore +# e.g. ["/**/?.txt", "/**/[0-9].txt", "/**/*\?*.txt"] +# +#"ignore" : [], +# +# The dest allows you to specify a file name in Crowdin +# e.g. "/messages.json" +# +#"dest" : "", +# +# File type +# e.g. "json" +# +#"type" : "", +# +# The parameter "update_option" is optional. If it is not set, after the files update the translations for changed strings will be removed. Use to fix typos and for minor changes in the source strings +# e.g. "update_as_unapproved" or "update_without_changes" +# +#"update_option" : "", +# +# Start block (for XML only) +# +# +# Defines whether to translate tags attributes. +# e.g. 0 or 1 (Default is 1) +# +# "translate_attributes" : 1, +# +# Defines whether to translate texts placed inside the tags. +# e.g. 0 or 1 (Default is 1) +# +# "translate_content" : 1, +# +# This is an array of strings, where each item is the XPaths to DOM element that should be imported +# e.g. ["/content/text", "/content/text[@value]"] +# +# "translatable_elements" : [], +# +# Defines whether to split long texts into smaller text segments +# e.g. 0 or 1 (Default is 1) +# +# "content_segmentation" : 1, +# +# End block (for XML only) +# +# +# Start .properties block +# +# +# Defines whether single quote should be escaped by another single quote or backslash in exported translations +# e.g. 0 or 1 or 2 or 3 (Default is 3) +# 0 - do not escape single quote; +# 1 - escape single quote by another single quote; +# 2 - escape single quote by backslash; +# 3 - escape single quote by another single quote only in strings containing variables ( {0} ). +# +# "escape_quotes" : 3, +# +# Defines whether any special characters (=, :, ! and #) should be escaped by backslash in exported translations. +# e.g. 0 or 1 (Default is 0) +# 0 - do not escape special characters +# 1 - escape special characters by a backslash +# +# "escape_special_characters": 0 +# +# +# End .properties block +# +# +# Often software projects have custom names for the directories where translations are placed. crowdin-cli allows you to map your own languages to be understandable by Crowdin. +# +#"languages_mapping" : { +# "two_letters_code" : { +# "crowdin_language_code" : "local_name" +# } +#}, +# +# Does the first line contain header? +# e.g. true or false +# +#"first_line_contains_header" : true, +# +# for spreadsheets +# e.g. "identifier,source_phrase,context,uk,ru,fr" +# +# "scheme" : "", diff --git a/examples/bootstrap/docs/doc1.md b/examples/bootstrap/docs/doc1.md index c80f08daf555..925a55c554bf 100644 --- a/examples/bootstrap/docs/doc1.md +++ b/examples/bootstrap/docs/doc1.md @@ -29,9 +29,9 @@ To serve as an example page when styling markdown based Docusaurus sites. ## Emphasis -Emphasis, aka italics, with *asterisks* or _underscores_. +Emphasis, aka italics, with _asterisks_ or _underscores_. -Strong emphasis, aka bold, with **asterisks** or __underscores__. +Strong emphasis, aka bold, with **asterisks** or **underscores**. Combined emphasis with **asterisks and _underscores_**. @@ -48,11 +48,11 @@ Strikethrough uses two tildes. ~~Scratch this.~~ 1. Ordered sub-list 1. And another item. -* Unordered list can use asterisks +- Unordered list can use asterisks -- Or minuses +* Or minuses -+ Or pluses +- Or pluses --- @@ -92,7 +92,6 @@ Images from any folder can be used by providing path to file. Path should be rel ![img](../static/img/logo.svg) - --- ## Code diff --git a/examples/classic/docs/doc1.md b/examples/classic/docs/doc1.md index 28286ecc7250..925a55c554bf 100644 --- a/examples/classic/docs/doc1.md +++ b/examples/classic/docs/doc1.md @@ -29,9 +29,9 @@ To serve as an example page when styling markdown based Docusaurus sites. ## Emphasis -Emphasis, aka italics, with *asterisks* or _underscores_. +Emphasis, aka italics, with _asterisks_ or _underscores_. -Strong emphasis, aka bold, with **asterisks** or __underscores__. +Strong emphasis, aka bold, with **asterisks** or **underscores**. Combined emphasis with **asterisks and _underscores_**. @@ -48,11 +48,11 @@ Strikethrough uses two tildes. ~~Scratch this.~~ 1. Ordered sub-list 1. And another item. -* Unordered list can use asterisks +- Unordered list can use asterisks -- Or minuses +* Or minuses -+ Or pluses +- Or pluses --- diff --git a/examples/facebook/docs/doc1.md b/examples/facebook/docs/doc1.md index 28286ecc7250..925a55c554bf 100644 --- a/examples/facebook/docs/doc1.md +++ b/examples/facebook/docs/doc1.md @@ -29,9 +29,9 @@ To serve as an example page when styling markdown based Docusaurus sites. ## Emphasis -Emphasis, aka italics, with *asterisks* or _underscores_. +Emphasis, aka italics, with _asterisks_ or _underscores_. -Strong emphasis, aka bold, with **asterisks** or __underscores__. +Strong emphasis, aka bold, with **asterisks** or **underscores**. Combined emphasis with **asterisks and _underscores_**. @@ -48,11 +48,11 @@ Strikethrough uses two tildes. ~~Scratch this.~~ 1. Ordered sub-list 1. And another item. -* Unordered list can use asterisks +- Unordered list can use asterisks -- Or minuses +* Or minuses -+ Or pluses +- Or pluses --- diff --git a/jest.config.js b/jest.config.js index 152c5a3fadde..4a7d7d20fe95 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,8 @@ const ignorePatterns = [ '/packages/docusaurus-plugin-content-blog/lib', '/packages/docusaurus-plugin-content-docs/lib', '/packages/docusaurus-plugin-content-pages/lib', + '/packages/docusaurus-theme-classic/lib', + '/packages/docusaurus-theme-classic/lib-next', '/packages/docusaurus-migrate/lib', ]; diff --git a/package.json b/package.json index 685c9eb77094..89c493db82e2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "serve:v2:ssl:gencert": "openssl req -x509 -nodes -days 365 -newkey rsa:4096 -subj \"/C=US/ST=Docusaurus/L=Anywhere/O=Dis/CN=localhost\" -keyout ./website/.docusaurus/selfsigned.key -out ./website/.docusaurus/selfsigned.crt", "serve:v2:ssl:message": "echo '\n\n\nServing Docusaurus with HTTPS on localhost requires to disable the Chrome security: chrome://flags/#allow-insecure-localhost\n\n\n'", "serve:v2:ssl:serve": "serve website/build --ssl-cert ./website/.docusaurus/selfsigned.crt --ssl-key ./website/.docusaurus/selfsigned.key", + "crowdin:upload:v2": "crowdin upload sources --config ./crowdin-v2.yaml", + "crowdin:uploadTranslations:v2": "crowdin upload translations --config ./crowdin-v2.yaml", + "crowdin:download:v2": "crowdin download --config ./crowdin-v2.yaml", "changelog": "lerna-changelog", "postinstall": "yarn lock:update && yarn build:packages", "prettier": "prettier --config .prettierrc --write \"**/*.{js,ts}\"", @@ -60,6 +63,7 @@ "@babel/core": "^7.12.3", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", "@babel/plugin-proposal-optional-chaining": "^7.12.1", + "@babel/plugin-transform-modules-commonjs": "^7.12.1", "@babel/preset-typescript": "^7.12.1", "@types/express": "^4.17.2", "@types/fs-extra": "^9.0.4", @@ -92,6 +96,7 @@ "@typescript-eslint/parser": "^4.8.0", "babel-eslint": "^10.0.3", "concurrently": "^5.2.0", + "cross-env": "^7.0.2", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.1", "eslint": "^7.13.0", diff --git a/packages/docusaurus-1.x/lib/server/__tests__/utils.test.js b/packages/docusaurus-1.x/lib/server/__tests__/utils.test.js index 5a8de6bfb966..660f39dde843 100644 --- a/packages/docusaurus-1.x/lib/server/__tests__/utils.test.js +++ b/packages/docusaurus-1.x/lib/server/__tests__/utils.test.js @@ -40,7 +40,7 @@ describe('server utils', () => { expect(css).toMatchSnapshot(); await expect(utils.minifyCss(notCss)).rejects.toMatchSnapshot(); - }); + }, 10000); test('autoprefix css', async () => { const testCss = fs.readFileSync( diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 55a9be9234bf..aa70ea2cd691 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -47,6 +47,20 @@ declare module '@generated/globalData' { export default globalData; } +declare module '@generated/i18n' { + const i18n: { + defaultLocale: string; + locales: [string, ...string[]]; + currentLocale: string; + }; + export default i18n; +} + +declare module '@generated/codeTranslations' { + const codeTranslations: Record; + export default codeTranslations; +} + declare module '@theme/*'; declare module '@theme-original/*'; @@ -69,6 +83,18 @@ declare module '@docusaurus/Link' { export default Link; } +declare module '@docusaurus/Translate' { + type TranslateProps = {children: string; id?: string; description?: string}; + const Translate: (props: TranslateProps) => JSX.Element; + export default Translate; + + export function translate(param: { + message: string; + id?: string; + description?: string; + }): string; +} + declare module '@docusaurus/router' { export const Redirect: (props: {to: string}) => import('react').Component; export function matchPath( diff --git a/packages/docusaurus-plugin-client-redirects/src/index.ts b/packages/docusaurus-plugin-client-redirects/src/index.ts index ef630903c8c7..a58298162e4d 100644 --- a/packages/docusaurus-plugin-client-redirects/src/index.ts +++ b/packages/docusaurus-plugin-client-redirects/src/index.ts @@ -14,7 +14,7 @@ import writeRedirectFiles, { toRedirectFilesMetadata, RedirectFileMetadata, } from './writeRedirectFiles'; -import {removePrefix} from '@docusaurus/utils'; +import {removePrefix, addLeadingSlash} from '@docusaurus/utils'; export default function pluginClientRedirectsPages( _context: LoadContext, @@ -27,7 +27,7 @@ export default function pluginClientRedirectsPages( async postBuild(props: Props) { const pluginContext: PluginContext = { relativeRoutesPaths: props.routesPaths.map( - (path) => `/${removePrefix(path, props.baseUrl)}`, + (path) => `${addLeadingSlash(removePrefix(path, props.baseUrl))}`, ), baseUrl: props.baseUrl, outDir: props.outDir, diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog-with-ref/post-with-broken-links.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog-with-ref/post-with-broken-links.md new file mode 100644 index 000000000000..a08e84eea0f3 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog-with-ref/post-with-broken-links.md @@ -0,0 +1,11 @@ +--- +title: This post links to another one! +--- + +[Good link 1](2018-12-14-Happy-First-Birthday-Slash.md) + +[Good link 2](./2018-12-14-Happy-First-Birthday-Slash.md) + +[Bad link 1](postNotExist1.md) + +[Bad link 1](./postNotExist2.mdx) diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md index c04bd7b802a0..a66bb945bf7c 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md @@ -2,4 +2,4 @@ title: Happy 1st Birthday Slash! --- -pattern name +Happy birthday! diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md new file mode 100644 index 000000000000..0f8fcd88e842 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md @@ -0,0 +1,5 @@ +--- +title: Happy 1st Birthday Slash! (translated) +--- + +Happy birthday! (translated) diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap index 56d761b87883..a31366164741 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap @@ -40,11 +40,11 @@ exports[`blogFeed atom shows feed item for each post 1`] = ` - <![CDATA[Happy 1st Birthday Slash!]]> - Happy 1st Birthday Slash! + <![CDATA[Happy 1st Birthday Slash! (translated)]]> + Happy 1st Birthday Slash! (translated) 2018-12-14T00:00:00.000Z - + " `; @@ -90,11 +90,11 @@ exports[`blogFeed rss shows feed item for each post 1`] = ` - <![CDATA[Happy 1st Birthday Slash!]]> + <![CDATA[Happy 1st Birthday Slash! (translated)]]> https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash - Happy 1st Birthday Slash! + Happy 1st Birthday Slash! (translated) Fri, 14 Dec 2018 00:00:00 GMT - + " diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/linkify.test.ts.snap b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/linkify.test.ts.snap index eb124f41c131..f1c28fabbb0f 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/linkify.test.ts.snap +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/linkify.test.ts.snap @@ -1,5 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`report broken markdown links 1`] = ` +"--- +title: This post links to another one! +--- + +[Good link 1](/blog/2018/12/14/Happy-First-Birthday-Slash) + +[Good link 2](/blog/2018/12/14/Happy-First-Birthday-Slash) + +[Bad link 1](postNotExist1.md) + +[Bad link 1](./postNotExist2.mdx) +" +`; + exports[`transform to correct link 1`] = ` "--- title: This post links to another one! diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts index 62d9dd256a51..7ef167360aa4 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts @@ -8,12 +8,25 @@ import path from 'path'; import {generateBlogFeed} from '../blogUtils'; import {LoadContext} from '@docusaurus/types'; -import {PluginOptions} from '../types'; +import {PluginOptions, BlogContentPaths} from '../types'; + +function getBlogContentPaths(siteDir: string): BlogContentPaths { + return { + contentPath: path.resolve(siteDir, 'blog'), + contentPathLocalized: path.resolve( + siteDir, + 'i18n', + 'en', + 'docusaurus-plugin-content-blog', + ), + }; +} describe('blogFeed', () => { - ['atom', 'rss'].forEach((feedType) => { + (['atom', 'rss'] as const).forEach((feedType) => { describe(`${feedType}`, () => { test('can show feed without posts', async () => { + const siteDir = __dirname; const siteConfig = { title: 'Hello', baseUrl: '/', @@ -22,8 +35,9 @@ describe('blogFeed', () => { }; const feed = await generateBlogFeed( + getBlogContentPaths(siteDir), { - siteDir: __dirname, + siteDir, siteConfig, } as LoadContext, { @@ -31,7 +45,7 @@ describe('blogFeed', () => { routeBasePath: 'blog', include: ['*.md', '*.mdx'], feedOptions: { - type: feedType, + type: [feedType], copyright: 'Copyright', }, } as PluginOptions, @@ -52,6 +66,7 @@ describe('blogFeed', () => { }; const feed = await generateBlogFeed( + getBlogContentPaths(siteDir), { siteDir, siteConfig, @@ -62,7 +77,7 @@ describe('blogFeed', () => { routeBasePath: 'blog', include: ['*r*.md', '*.mdx'], // skip no-date.md - it won't play nice with snapshots feedOptions: { - type: feedType, + type: [feedType], copyright: 'Copyright', }, } as PluginOptions, diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts index 76dc827e3e80..a26442d6d441 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts @@ -8,9 +8,15 @@ import fs from 'fs-extra'; import path from 'path'; import pluginContentBlog from '../index'; -import {DocusaurusConfig, LoadContext} from '@docusaurus/types'; +import {DocusaurusConfig, LoadContext, I18n} from '@docusaurus/types'; import {PluginOptionSchema} from '../pluginOptionSchema'; +const DefaultI18N: I18n = { + currentLocale: 'en', + locales: ['en'], + defaultLocale: 'en', +}; + function validateAndNormalize(schema, options) { const {value, error} = schema.validate(options); if (error) { @@ -34,6 +40,7 @@ describe('loadBlog', () => { siteDir, siteConfig, generatedFilesDir, + i18n: DefaultI18N, } as LoadContext, validateAndNormalize(PluginOptionSchema, { path: pluginPath, @@ -66,26 +73,28 @@ describe('loadBlog', () => { tags: [], nextItem: { permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash', - title: 'Happy 1st Birthday Slash!', + title: 'Happy 1st Birthday Slash! (translated)', }, truncated: false, }); expect( - blogPosts.find((v) => v.metadata.title === 'Happy 1st Birthday Slash!') - .metadata, + blogPosts.find( + (v) => v.metadata.title === 'Happy 1st Birthday Slash! (translated)', + ).metadata, ).toEqual({ editUrl: - 'https://github.com/facebook/docusaurus/edit/master/website-1x/blog/2018-12-14-Happy-First-Birthday-Slash.md', + 'https://github.com/facebook/docusaurus/edit/master/website-1x/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md', permalink: '/blog/2018/12/14/Happy-First-Birthday-Slash', - readingTime: 0.01, + readingTime: 0.015, source: path.join( '@site', - pluginPath, + // pluginPath, + path.join('i18n', 'en', 'docusaurus-plugin-content-blog'), '2018-12-14-Happy-First-Birthday-Slash.md', ), - title: 'Happy 1st Birthday Slash!', - description: `pattern name`, + title: 'Happy 1st Birthday Slash! (translated)', + description: `Happy birthday! (translated)`, date: new Date('2018-12-14'), tags: [], prevItem: { diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/linkify.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/linkify.test.ts index 162e4d6ac43c..3b30ba1e3947 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/linkify.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/linkify.test.ts @@ -7,11 +7,14 @@ import fs from 'fs-extra'; import path from 'path'; -import {linkify} from '../blogUtils'; -import {BlogPost} from '../types'; +import {linkify, LinkifyParams} from '../blogUtils'; +import {BlogBrokenMarkdownLink, BlogContentPaths, BlogPost} from '../types'; -const sitePath = path.join(__dirname, '__fixtures__', 'website'); -const blogPath = path.join(sitePath, 'blog-with-ref'); +const siteDir = path.join(__dirname, '__fixtures__', 'website'); +const contentPaths: BlogContentPaths = { + contentPath: path.join(siteDir, 'blog-with-ref'), + contentPathLocalized: path.join(siteDir, 'blog-with-ref-localized'), +}; const pluginDir = 'blog-with-ref'; const blogPosts: BlogPost[] = [ { @@ -36,14 +39,26 @@ const blogPosts: BlogPost[] = [ }, ]; -const transform = (filepath: string) => { - const content = fs.readFileSync(filepath, 'utf-8'); - const transformedContent = linkify(content, sitePath, blogPath, blogPosts); - return [content, transformedContent]; +const transform = (filePath: string, options?: Partial) => { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + const transformedContent = linkify({ + filePath, + fileContent, + siteDir, + contentPaths, + blogPosts, + onBrokenMarkdownLink: (brokenMarkdownLink) => { + throw new Error( + `Broken markdown link found: ${JSON.stringify(brokenMarkdownLink)}`, + ); + }, + ...options, + }); + return [fileContent, transformedContent]; }; test('transform to correct link', () => { - const post = path.join(blogPath, 'post.md'); + const post = path.join(contentPaths.contentPath, 'post.md'); const [content, transformedContent] = transform(post); expect(transformedContent).toMatchSnapshot(); expect(transformedContent).toContain( @@ -54,3 +69,25 @@ test('transform to correct link', () => { ); expect(content).not.toEqual(transformedContent); }); + +test('report broken markdown links', () => { + const filePath = 'post-with-broken-links.md'; + const folderPath = contentPaths.contentPath; + const postWithBrokenLinks = path.join(folderPath, filePath); + const onBrokenMarkdownLink = jest.fn(); + const [, transformedContent] = transform(postWithBrokenLinks, { + onBrokenMarkdownLink, + }); + expect(transformedContent).toMatchSnapshot(); + expect(onBrokenMarkdownLink).toHaveBeenCalledTimes(2); + expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(1, { + filePath: path.resolve(folderPath, filePath), + folderPath, + link: 'postNotExist1.md', + } as BlogBrokenMarkdownLink); + expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(2, { + filePath: path.resolve(folderPath, filePath), + folderPath, + link: './postNotExist2.mdx', + } as BlogBrokenMarkdownLink); +}); diff --git a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts index 9d2314122d75..6c1963cd8f8a 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -11,14 +11,23 @@ import chalk from 'chalk'; import path from 'path'; import readingTime from 'reading-time'; import {Feed} from 'feed'; -import {PluginOptions, BlogPost, DateLink} from './types'; +import { + PluginOptions, + BlogPost, + DateLink, + BlogContentPaths, + BlogBrokenMarkdownLink, + BlogMarkdownLoaderOptions, +} from './types'; import { parseMarkdownFile, normalizeUrl, aliasedSitePath, getEditUrl, + getFolderContainingFile, } from '@docusaurus/utils'; import {LoadContext} from '@docusaurus/types'; +import {keyBy} from 'lodash'; export function truncate(fileString: string, truncateMarker: RegExp): string { return fileString.split(truncateMarker, 1).shift()!; @@ -36,6 +45,7 @@ function toUrl({date, link}: DateLink) { } export async function generateBlogFeed( + contentPaths: BlogContentPaths, context: LoadContext, options: PluginOptions, ): Promise { @@ -44,9 +54,8 @@ export async function generateBlogFeed( 'Invalid options - `feedOptions` is not expected to be null.', ); } - const {siteDir, siteConfig} = context; - const contentPath = path.resolve(siteDir, options.path); - const blogPosts = await generateBlogPosts(contentPath, context, options); + const {siteConfig} = context; + const blogPosts = await generateBlogPosts(contentPaths, context, options); if (blogPosts == null) { return null; } @@ -88,7 +97,7 @@ export async function generateBlogFeed( } export async function generateBlogPosts( - blogDir: string, + contentPaths: BlogContentPaths, {siteConfig, siteDir}: LoadContext, options: PluginOptions, ): Promise { @@ -100,24 +109,30 @@ export async function generateBlogPosts( editUrl, } = options; - if (!fs.existsSync(blogDir)) { + if (!fs.existsSync(contentPaths.contentPath)) { return []; } const {baseUrl = ''} = siteConfig; - const blogFiles = await globby(include, { - cwd: blogDir, + const blogSourceFiles = await globby(include, { + cwd: contentPaths.contentPath, }); const blogPosts: BlogPost[] = []; await Promise.all( - blogFiles.map(async (relativeSource: string) => { - const source = path.join(blogDir, relativeSource); + blogSourceFiles.map(async (blogSourceFile: string) => { + // Lookup in localized folder in priority + const contentPath = await getFolderContainingFile( + getContentPathList(contentPaths), + blogSourceFile, + ); + + const source = path.join(contentPath, blogSourceFile); const aliasedSource = aliasedSitePath(source, siteDir); - const refDir = path.parse(blogDir).dir; - const relativePath = path.relative(refDir, source); - const blogFileName = path.basename(relativeSource); + + const relativePath = path.relative(siteDir, source); + const blogFileName = path.basename(blogSourceFile); const editBlogUrl = getEditUrl(relativePath, editUrl); @@ -184,12 +199,31 @@ export async function generateBlogPosts( return blogPosts; } -export function linkify( - fileContent: string, - siteDir: string, - blogPath: string, - blogPosts: BlogPost[], -): string { +export type LinkifyParams = { + filePath: string; + fileContent: string; +} & Pick< + BlogMarkdownLoaderOptions, + 'blogPosts' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink' +>; + +export function linkify({ + filePath, + contentPaths, + fileContent, + siteDir, + blogPosts, + onBrokenMarkdownLink, +}: LinkifyParams): string { + // TODO temporary, should consider the file being in localized folder! + const folderPath = contentPaths.contentPath; + + // TODO perf refactor: do this earlier (once for all md files, not per file) + const blogPostsBySource: Record = keyBy( + blogPosts, + (item) => item.metadata.source, + ); + let fencedBlock = false; const lines = fileContent.split('\n').map((line) => { if (line.trim().startsWith('```')) { @@ -208,18 +242,24 @@ export function linkify( const mdLink = mdMatch[1]; const aliasedPostSource = `@site/${path.relative( siteDir, - path.resolve(blogPath, mdLink), + path.resolve(folderPath, mdLink), )}`; - let blogPostPermalink = null; - blogPosts.forEach((blogPost) => { - if (blogPost.metadata.source === aliasedPostSource) { - blogPostPermalink = blogPost.metadata.permalink; - } - }); + const blogPost: BlogPost | undefined = + blogPostsBySource[aliasedPostSource]; - if (blogPostPermalink) { - modifiedLine = modifiedLine.replace(mdLink, blogPostPermalink); + if (blogPost) { + modifiedLine = modifiedLine.replace( + mdLink, + blogPost.metadata.permalink, + ); + } else { + const brokenMarkdownLink: BlogBrokenMarkdownLink = { + folderPath, + filePath, + link: mdLink, + }; + onBrokenMarkdownLink(brokenMarkdownLink); } mdMatch = mdRegex.exec(modifiedLine); @@ -230,3 +270,8 @@ export function linkify( return lines.join('\n'); } + +// Order matters: we look in priority in localized folder +export function getContentPathList(contentPaths: BlogContentPaths) { + return [contentPaths.contentPathLocalized, contentPaths.contentPath]; +} diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index f4b073f49aaa..022765d9f153 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -8,13 +8,19 @@ import fs from 'fs-extra'; import path from 'path'; import admonitions from 'remark-admonitions'; -import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils'; +import { + normalizeUrl, + docuHash, + aliasedSitePath, + getPluginI18nPath, + reportMessage, +} from '@docusaurus/utils'; import { STATIC_DIR_NAME, DEFAULT_PLUGIN_ID, } from '@docusaurus/core/lib/constants'; import {ValidationError} from 'joi'; -import {take, kebabCase} from 'lodash'; +import {flatten, take, kebabCase} from 'lodash'; import { PluginOptions, @@ -24,6 +30,8 @@ import { TagsModule, BlogPaginated, BlogPost, + BlogContentPaths, + BlogMarkdownLoaderOptions, } from './types'; import {PluginOptionSchema} from './pluginOptionSchema'; import { @@ -36,7 +44,11 @@ import { ValidationResult, } from '@docusaurus/types'; import {Configuration, Loader} from 'webpack'; -import {generateBlogFeed, generateBlogPosts} from './blogUtils'; +import { + generateBlogFeed, + generateBlogPosts, + getContentPathList, +} from './blogUtils'; export default function pluginContentBlog( context: LoadContext, @@ -48,8 +60,22 @@ export default function pluginContentBlog( ]); } - const {siteDir, generatedFilesDir} = context; - const contentPath = path.resolve(siteDir, options.path); + const { + siteDir, + siteConfig: {onBrokenMarkdownLinks}, + generatedFilesDir, + i18n: {currentLocale}, + } = context; + + const contentPaths: BlogContentPaths = { + contentPath: path.resolve(siteDir, options.path), + contentPathLocalized: getPluginI18nPath({ + siteDir, + locale: currentLocale, + pluginName: 'docusaurus-plugin-content-blog', + pluginId: options.id, + }), + }; const pluginId = options.id ?? DEFAULT_PLUGIN_ID; const pluginDataDirRoot = path.join( @@ -67,8 +93,11 @@ export default function pluginContentBlog( getPathsToWatch() { const {include = []} = options; - const globPattern = include.map((pattern) => `${contentPath}/${pattern}`); - return [...globPattern]; + return flatten( + getContentPathList(contentPaths).map((contentPath) => { + return include.map((pattern) => `${contentPath}/${pattern}`); + }), + ); }, getClientModules() { @@ -85,7 +114,7 @@ export default function pluginContentBlog( async loadContent() { const {postsPerPage, routeBasePath} = options; - blogPosts = await generateBlogPosts(contentPath, context, options); + blogPosts = await generateBlogPosts(contentPaths, context, options); if (!blogPosts.length) { return null; } @@ -379,6 +408,23 @@ export default function pluginContentBlog( beforeDefaultRemarkPlugins, beforeDefaultRehypePlugins, } = options; + + const markdownLoaderOptions: BlogMarkdownLoaderOptions = { + siteDir, + contentPaths, + truncateMarker, + blogPosts, + onBrokenMarkdownLink: (brokenMarkdownLink) => { + if (onBrokenMarkdownLinks === 'ignore') { + return; + } + reportMessage( + `Blog markdown link couldn't be resolved: (${brokenMarkdownLink.link}) in ${brokenMarkdownLink.filePath}`, + onBrokenMarkdownLinks, + ); + }, + }; + return { resolve: { alias: { @@ -389,7 +435,7 @@ export default function pluginContentBlog( rules: [ { test: /(\.mdx?)$/, - include: [contentPath], + include: getContentPathList(contentPaths), use: [ getCacheLoader(isServer), getBabelLoader(isServer), @@ -414,12 +460,7 @@ export default function pluginContentBlog( }, { loader: path.resolve(__dirname, './markdownLoader.js'), - options: { - siteDir, - contentPath, - truncateMarker, - blogPosts, - }, + options: markdownLoaderOptions, }, ].filter(Boolean) as Loader[], }, @@ -433,7 +474,7 @@ export default function pluginContentBlog( return; } - const feed = await generateBlogFeed(context, options); + const feed = await generateBlogFeed(contentPaths, context, options); if (!feed) { return; diff --git a/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts b/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts index e0e7844adb3e..0b37de13cef2 100644 --- a/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts +++ b/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts @@ -8,19 +8,20 @@ import {loader} from 'webpack'; import {truncate, linkify} from './blogUtils'; import {parseQuery, getOptions} from 'loader-utils'; +import {BlogMarkdownLoaderOptions} from './types'; const markdownLoader: loader.Loader = function (source) { - const fileString = source as string; + const filePath = this.resourcePath; + const fileContent = source as string; const callback = this.async(); - const {truncateMarker, siteDir, contentPath, blogPosts} = getOptions(this); + const markdownLoaderOptions = getOptions(this) as BlogMarkdownLoaderOptions; - // Linkify posts - let finalContent = linkify( - fileString as string, - siteDir, - contentPath, - blogPosts, - ); + // Linkify blog posts + let finalContent = linkify({ + fileContent, + filePath, + ...markdownLoaderOptions, + }); // Truncate content if requested (e.g: file.md?truncated=true). const truncated: string | undefined = this.resourceQuery @@ -28,7 +29,7 @@ const markdownLoader: loader.Loader = function (source) { : undefined; if (truncated) { - finalContent = truncate(finalContent, truncateMarker); + finalContent = truncate(finalContent, markdownLoaderOptions.truncateMarker); } return callback && callback(null, finalContent); diff --git a/packages/docusaurus-plugin-content-blog/src/types.ts b/packages/docusaurus-plugin-content-blog/src/types.ts index 0ee2e2acc66a..93554fda5a04 100644 --- a/packages/docusaurus-plugin-content-blog/src/types.ts +++ b/packages/docusaurus-plugin-content-blog/src/types.ts @@ -5,6 +5,11 @@ * LICENSE file in the root directory of this source tree. */ +export type BlogContentPaths = { + contentPath: string; + contentPathLocalized: string; +}; + export interface BlogContent { blogPosts: BlogPost[]; blogListPaginated: BlogPaginated[]; @@ -121,3 +126,16 @@ export interface TagModule { count: number; permalink: string; } + +export type BlogBrokenMarkdownLink = { + folderPath: string; + filePath: string; + link: string; +}; +export type BlogMarkdownLoaderOptions = { + siteDir: string; + contentPaths: BlogContentPaths; + truncateMarker: RegExp; + blogPosts: BlogPost[]; + onBrokenMarkdownLink: (brokenMarkdownLink: BlogBrokenMarkdownLink) => void; +}; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md new file mode 100644 index 000000000000..0859012fdccc --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md @@ -0,0 +1,5 @@ +--- +title: Team title translated +--- + +Team current version (translated) diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md new file mode 100644 index 000000000000..3589befaf984 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__fixtures__/versioned-site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md @@ -0,0 +1 @@ +Hello `1.0.0` ! (translated) diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap index 995f2f5c63db..ca75bc9d08e2 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap @@ -616,18 +616,6 @@ Object { exports[`versioned website (community) content: data 1`] = ` Object { - "site-community-team-md-9d8.json": "{ - \\"unversionedId\\": \\"team\\", - \\"id\\": \\"team\\", - \\"isDocsHomePage\\": false, - \\"title\\": \\"team\\", - \\"description\\": \\"Team current version\\", - \\"source\\": \\"@site/community/team.md\\", - \\"slug\\": \\"/team\\", - \\"permalink\\": \\"/community/next/team\\", - \\"version\\": \\"current\\", - \\"sidebar\\": \\"community\\" -}", "site-community-versioned-docs-version-1-0-0-team-md-359.json": "{ \\"unversionedId\\": \\"team\\", \\"id\\": \\"version-1.0.0/team\\", @@ -639,6 +627,18 @@ Object { \\"permalink\\": \\"/community/team\\", \\"version\\": \\"1.0.0\\", \\"sidebar\\": \\"version-1.0.0/community\\" +}", + "site-i-18-n-en-docusaurus-plugin-content-docs-community-current-team-md-7e5.json": "{ + \\"unversionedId\\": \\"team\\", + \\"id\\": \\"team\\", + \\"isDocsHomePage\\": false, + \\"title\\": \\"Team title translated\\", + \\"description\\": \\"Team current version (translated)\\", + \\"source\\": \\"@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md\\", + \\"slug\\": \\"/team\\", + \\"permalink\\": \\"/community/next/team\\", + \\"version\\": \\"current\\", + \\"sidebar\\": \\"community\\" }", "version-1-0-0-metadata-prop-608.json": "{ \\"pluginId\\": \\"community\\", @@ -667,7 +667,7 @@ Object { \\"community\\": [ { \\"type\\": \\"link\\", - \\"label\\": \\"team\\", + \\"label\\": \\"Team title translated\\", \\"href\\": \\"/community/next/team\\" } ] @@ -734,7 +734,7 @@ Array [ "component": "@theme/DocItem", "exact": true, "modules": Object { - "content": "@site/community/team.md", + "content": "@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md", }, "path": "/community/next/team", }, @@ -930,6 +930,22 @@ Object { \\"slug\\": \\"/tryToEscapeSlug\\", \\"permalink\\": \\"/docs/next/tryToEscapeSlug\\", \\"version\\": \\"current\\" +}", + "site-i-18-n-en-docusaurus-plugin-content-docs-version-1-0-0-hello-md-fe5.json": "{ + \\"unversionedId\\": \\"hello\\", + \\"id\\": \\"version-1.0.0/hello\\", + \\"isDocsHomePage\\": true, + \\"title\\": \\"hello\\", + \\"description\\": \\"Hello 1.0.0 ! (translated)\\", + \\"source\\": \\"@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md\\", + \\"slug\\": \\"/\\", + \\"permalink\\": \\"/docs/1.0.0/\\", + \\"version\\": \\"1.0.0\\", + \\"sidebar\\": \\"version-1.0.0/docs\\", + \\"previous\\": { + \\"title\\": \\"baz\\", + \\"permalink\\": \\"/docs/1.0.0/foo/baz\\" + } }", "site-versioned-docs-version-1-0-0-foo-bar-md-7a6.json": "{ \\"unversionedId\\": \\"foo/bar\\", @@ -966,22 +982,6 @@ Object { \\"title\\": \\"hello\\", \\"permalink\\": \\"/docs/1.0.0/\\" } -}", - "site-versioned-docs-version-1-0-0-hello-md-3ef.json": "{ - \\"unversionedId\\": \\"hello\\", - \\"id\\": \\"version-1.0.0/hello\\", - \\"isDocsHomePage\\": true, - \\"title\\": \\"hello\\", - \\"description\\": \\"Hello 1.0.0 !\\", - \\"source\\": \\"@site/versioned_docs/version-1.0.0/hello.md\\", - \\"slug\\": \\"/\\", - \\"permalink\\": \\"/docs/1.0.0/\\", - \\"version\\": \\"1.0.0\\", - \\"sidebar\\": \\"version-1.0.0/docs\\", - \\"previous\\": { - \\"title\\": \\"baz\\", - \\"permalink\\": \\"/docs/1.0.0/foo/baz\\" - } }", "site-versioned-docs-version-1-0-1-foo-bar-md-7a3.json": "{ \\"unversionedId\\": \\"foo/bar\\", @@ -1410,7 +1410,7 @@ Array [ "component": "@theme/DocItem", "exact": true, "modules": Object { - "content": "@site/versioned_docs/version-1.0.0/hello.md", + "content": "@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md", }, "path": "/docs/1.0.0/", }, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/translations.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/translations.test.ts.snap new file mode 100644 index 000000000000..73415461db93 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/translations.test.ts.snap @@ -0,0 +1,487 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getLoadedContentTranslationFiles should return translation files matching snapshot 1`] = ` +Array [ + Object { + "content": Object { + "sidebar.docs.category.Getting started": Object { + "description": "The label for category Getting started in sidebar docs", + "message": "Getting started", + }, + "sidebar.docs.link.Link label": Object { + "description": "The label for link Link label in sidebar docs, linking to https://facebook.com", + "message": "Link label", + }, + "version.label": Object { + "description": "The label for version current", + "message": "current label", + }, + }, + "path": "current", + }, + Object { + "content": Object { + "sidebar.docs.category.Getting started": Object { + "description": "The label for category Getting started in sidebar docs", + "message": "Getting started", + }, + "sidebar.docs.link.Link label": Object { + "description": "The label for link Link label in sidebar docs, linking to https://facebook.com", + "message": "Link label", + }, + "version.label": Object { + "description": "The label for version 2.0.0", + "message": "2.0.0 label", + }, + }, + "path": "version-2.0.0", + }, + Object { + "content": Object { + "sidebar.docs.category.Getting started": Object { + "description": "The label for category Getting started in sidebar docs", + "message": "Getting started", + }, + "sidebar.docs.link.Link label": Object { + "description": "The label for link Link label in sidebar docs, linking to https://facebook.com", + "message": "Link label", + }, + "version.label": Object { + "description": "The label for version 1.0.0", + "message": "1.0.0 label", + }, + }, + "path": "version-1.0.0", + }, +] +`; + +exports[`translateLoadedContent should return translated loaded content matching snapshot 1`] = ` +Object { + "loadedVersions": Array [ + Object { + "docs": Array [ + Object { + "description": "doc1 description", + "editUrl": "any", + "id": "doc1", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc1 title", + "slug": "any", + "source": "any", + "title": "doc1 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc2 description", + "editUrl": "any", + "id": "doc2", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc2 title", + "slug": "any", + "source": "any", + "title": "doc2 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc3 description", + "editUrl": "any", + "id": "doc3", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc3 title", + "slug": "any", + "source": "any", + "title": "doc3 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc4 description", + "editUrl": "any", + "id": "doc4", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc4 title", + "slug": "any", + "source": "any", + "title": "doc4 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc5 description", + "editUrl": "any", + "id": "doc5", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc5 title", + "slug": "any", + "source": "any", + "title": "doc5 title", + "unversionedId": "any", + "version": "any", + }, + ], + "docsDirPath": "any", + "docsDirPathLocalized": "any", + "isLast": true, + "mainDocId": "", + "permalinkToSidebar": Object {}, + "routePriority": undefined, + "sidebarFilePath": "any", + "sidebars": Object { + "docs": Array [ + Object { + "collapsed": false, + "items": Array [ + Object { + "id": "doc1", + "type": "doc", + }, + Object { + "id": "doc2", + "type": "doc", + }, + Object { + "href": "https://facebook.com", + "label": "Link label (translated)", + "type": "link", + }, + Object { + "id": "doc1", + "type": "ref", + }, + ], + "label": "Getting started (translated)", + "type": "category", + }, + Object { + "id": "doc3", + "type": "doc", + }, + ], + "otherSidebar": Array [ + Object { + "id": "doc4", + "type": "doc", + }, + Object { + "id": "doc5", + "type": "doc", + }, + ], + }, + "versionLabel": "current label (translated)", + "versionName": "current", + "versionPath": "/docs/", + }, + Object { + "docs": Array [ + Object { + "description": "doc1 description", + "editUrl": "any", + "id": "doc1", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc1 title", + "slug": "any", + "source": "any", + "title": "doc1 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc2 description", + "editUrl": "any", + "id": "doc2", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc2 title", + "slug": "any", + "source": "any", + "title": "doc2 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc3 description", + "editUrl": "any", + "id": "doc3", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc3 title", + "slug": "any", + "source": "any", + "title": "doc3 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc4 description", + "editUrl": "any", + "id": "doc4", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc4 title", + "slug": "any", + "source": "any", + "title": "doc4 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc5 description", + "editUrl": "any", + "id": "doc5", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc5 title", + "slug": "any", + "source": "any", + "title": "doc5 title", + "unversionedId": "any", + "version": "any", + }, + ], + "docsDirPath": "any", + "docsDirPathLocalized": "any", + "isLast": true, + "mainDocId": "", + "permalinkToSidebar": Object {}, + "routePriority": undefined, + "sidebarFilePath": "any", + "sidebars": Object { + "docs": Array [ + Object { + "collapsed": false, + "items": Array [ + Object { + "id": "doc1", + "type": "doc", + }, + Object { + "id": "doc2", + "type": "doc", + }, + Object { + "href": "https://facebook.com", + "label": "Link label (translated)", + "type": "link", + }, + Object { + "id": "doc1", + "type": "ref", + }, + ], + "label": "Getting started (translated)", + "type": "category", + }, + Object { + "id": "doc3", + "type": "doc", + }, + ], + "otherSidebar": Array [ + Object { + "id": "doc4", + "type": "doc", + }, + Object { + "id": "doc5", + "type": "doc", + }, + ], + }, + "versionLabel": "2.0.0 label (translated)", + "versionName": "2.0.0", + "versionPath": "/docs/", + }, + Object { + "docs": Array [ + Object { + "description": "doc1 description", + "editUrl": "any", + "id": "doc1", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc1 title", + "slug": "any", + "source": "any", + "title": "doc1 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc2 description", + "editUrl": "any", + "id": "doc2", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc2 title", + "slug": "any", + "source": "any", + "title": "doc2 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc3 description", + "editUrl": "any", + "id": "doc3", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc3 title", + "slug": "any", + "source": "any", + "title": "doc3 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc4 description", + "editUrl": "any", + "id": "doc4", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc4 title", + "slug": "any", + "source": "any", + "title": "doc4 title", + "unversionedId": "any", + "version": "any", + }, + Object { + "description": "doc5 description", + "editUrl": "any", + "id": "doc5", + "isDocsHomePage": false, + "lastUpdatedAt": 0, + "lastUpdatedBy": "any", + "next": undefined, + "permalink": "any", + "previous": undefined, + "sidebar_label": "doc5 title", + "slug": "any", + "source": "any", + "title": "doc5 title", + "unversionedId": "any", + "version": "any", + }, + ], + "docsDirPath": "any", + "docsDirPathLocalized": "any", + "isLast": true, + "mainDocId": "", + "permalinkToSidebar": Object {}, + "routePriority": undefined, + "sidebarFilePath": "any", + "sidebars": Object { + "docs": Array [ + Object { + "collapsed": false, + "items": Array [ + Object { + "id": "doc1", + "type": "doc", + }, + Object { + "id": "doc2", + "type": "doc", + }, + Object { + "href": "https://facebook.com", + "label": "Link label (translated)", + "type": "link", + }, + Object { + "id": "doc1", + "type": "ref", + }, + ], + "label": "Getting started (translated)", + "type": "category", + }, + Object { + "id": "doc3", + "type": "doc", + }, + ], + "otherSidebar": Array [ + Object { + "id": "doc4", + "type": "doc", + }, + Object { + "id": "doc5", + "type": "doc", + }, + ], + }, + "versionLabel": "1.0.0 label (translated)", + "versionName": "1.0.0", + "versionPath": "/docs/", + }, + ], +} +`; diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts index 34eedbe9cdfe..d5fbb71427e6 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/docs.test.ts @@ -42,6 +42,7 @@ ${markdown} source, content, lastUpdate: {}, + filePath: source, }; }; @@ -57,7 +58,7 @@ function createTestUtils({ options: MetadataOptions; }) { async function readDoc(docFileSource: string) { - return readDocFile(versionMetadata.docsDirPath, docFileSource, options); + return readDocFile(versionMetadata, docFileSource, options); } function processDocFile(docFile: DocFile) { return processDocMetadata({ @@ -110,30 +111,41 @@ function createTestUtils({ } describe('simple site', () => { - const siteDir = path.join(fixtureDir, 'simple-site'); - const context = loadContext(siteDir); - const options = { - id: DEFAULT_PLUGIN_ID, - ...DEFAULT_OPTIONS, - }; - const versionsMetadata = readVersionsMetadata({ - context, - options: { + async function loadSite() { + const siteDir = path.join(fixtureDir, 'simple-site'); + const context = await loadContext(siteDir); + const options = { id: DEFAULT_PLUGIN_ID, ...DEFAULT_OPTIONS, - }, - }); - expect(versionsMetadata.length).toEqual(1); - const [currentVersion] = versionsMetadata; - - const defaultTestUtils = createTestUtils({ - siteDir, - context, - options, - versionMetadata: currentVersion, - }); + }; + const versionsMetadata = readVersionsMetadata({ + context, + options: { + id: DEFAULT_PLUGIN_ID, + ...DEFAULT_OPTIONS, + }, + }); + expect(versionsMetadata.length).toEqual(1); + const [currentVersion] = versionsMetadata; + + const defaultTestUtils = createTestUtils({ + siteDir, + context, + options, + versionMetadata: currentVersion, + }); + return { + siteDir, + context, + options, + versionsMetadata, + defaultTestUtils, + currentVersion, + }; + } test('readVersionDocs', async () => { + const {options, currentVersion} = await loadSite(); const docs = await readVersionDocs(currentVersion, options); expect(docs.map((doc) => doc.source).sort()).toEqual( [ @@ -155,6 +167,7 @@ describe('simple site', () => { }); test('normal docs', async () => { + const {defaultTestUtils} = await loadSite(); await defaultTestUtils.testMeta(path.join('foo', 'bar.md'), { version: 'current', id: 'foo/bar', @@ -178,6 +191,8 @@ describe('simple site', () => { }); test('homePageId doc', async () => { + const {siteDir, context, options, currentVersion} = await loadSite(); + const testUtilsLocal = createTestUtils({ siteDir, context, @@ -198,6 +213,8 @@ describe('simple site', () => { }); test('homePageId doc nested', async () => { + const {siteDir, context, options, currentVersion} = await loadSite(); + const testUtilsLocal = createTestUtils({ siteDir, context, @@ -218,6 +235,8 @@ describe('simple site', () => { }); test('docs with editUrl', async () => { + const {siteDir, context, options, currentVersion} = await loadSite(); + const testUtilsLocal = createTestUtils({ siteDir, context, @@ -243,6 +262,8 @@ describe('simple site', () => { }); test('docs with custom editUrl & unrelated frontmatter', async () => { + const {defaultTestUtils} = await loadSite(); + await defaultTestUtils.testMeta('lorem.md', { version: 'current', id: 'lorem', @@ -257,6 +278,8 @@ describe('simple site', () => { }); test('docs with last update time and author', async () => { + const {siteDir, context, options, currentVersion} = await loadSite(); + const testUtilsLocal = createTestUtils({ siteDir, context, @@ -284,6 +307,8 @@ describe('simple site', () => { }); test('docs with slugs', async () => { + const {defaultTestUtils} = await loadSite(); + await defaultTestUtils.testSlug( path.join('rootRelativeSlug.md'), '/docs/rootRelativeSlug', @@ -319,7 +344,8 @@ describe('simple site', () => { ); }); - test('docs with invalid id', () => { + test('docs with invalid id', async () => { + const {defaultTestUtils} = await loadSite(); expect(() => { defaultTestUtils.processDocFile( createFakeDocFile({ @@ -335,6 +361,8 @@ describe('simple site', () => { }); test('docs with slug on doc home', async () => { + const {siteDir, context, options, currentVersion} = await loadSite(); + const testUtilsLocal = createTestUtils({ siteDir, context, @@ -360,55 +388,71 @@ describe('simple site', () => { }); describe('versioned site', () => { - const siteDir = path.join(fixtureDir, 'versioned-site'); - const context = loadContext(siteDir); - const options = { - id: DEFAULT_PLUGIN_ID, - ...DEFAULT_OPTIONS, - }; - const versionsMetadata = readVersionsMetadata({ - context, - options: { + async function loadSite() { + const siteDir = path.join(fixtureDir, 'versioned-site'); + const context = await loadContext(siteDir); + const options = { id: DEFAULT_PLUGIN_ID, ...DEFAULT_OPTIONS, - }, - }); - expect(versionsMetadata.length).toEqual(4); - const [ - currentVersion, - version101, - version100, - versionWithSlugs, - ] = versionsMetadata; - - const currentVersionTestUtils = createTestUtils({ - siteDir, - context, - options, - versionMetadata: currentVersion, - }); - const version101TestUtils = createTestUtils({ - siteDir, - context, - options, - versionMetadata: version101, - }); + }; + const versionsMetadata = readVersionsMetadata({ + context, + options: { + id: DEFAULT_PLUGIN_ID, + ...DEFAULT_OPTIONS, + }, + }); + expect(versionsMetadata.length).toEqual(4); + const [ + currentVersion, + version101, + version100, + versionWithSlugs, + ] = versionsMetadata; + + const currentVersionTestUtils = createTestUtils({ + siteDir, + context, + options, + versionMetadata: currentVersion, + }); + const version101TestUtils = createTestUtils({ + siteDir, + context, + options, + versionMetadata: version101, + }); - const version100TestUtils = createTestUtils({ - siteDir, - context, - options, - versionMetadata: version100, - }); + const version100TestUtils = createTestUtils({ + siteDir, + context, + options, + versionMetadata: version100, + }); - const versionWithSlugsTestUtils = createTestUtils({ - siteDir, - context, - options, - versionMetadata: versionWithSlugs, - }); + const versionWithSlugsTestUtils = createTestUtils({ + siteDir, + context, + options, + versionMetadata: versionWithSlugs, + }); + + return { + siteDir, + context, + options, + versionsMetadata, + currentVersionTestUtils, + version101TestUtils, + version100, + version100TestUtils, + versionWithSlugsTestUtils, + }; + } test('next docs', async () => { + const {currentVersionTestUtils} = await loadSite(); + await currentVersionTestUtils.testMeta(path.join('foo', 'bar.md'), { id: 'foo/bar', unversionedId: 'foo/bar', @@ -432,6 +476,8 @@ describe('versioned site', () => { }); test('versioned docs', async () => { + const {version101TestUtils, version100TestUtils} = await loadSite(); + await version100TestUtils.testMeta(path.join('foo', 'bar.md'), { id: 'version-1.0.0/foo/bar', unversionedId: 'foo/bar', @@ -449,8 +495,10 @@ describe('versioned site', () => { permalink: '/docs/1.0.0/hello', slug: '/hello', title: 'hello', - description: 'Hello 1.0.0 !', + description: 'Hello 1.0.0 ! (translated)', version: '1.0.0', + source: + '@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md', }); await version101TestUtils.testMeta(path.join('foo', 'bar.md'), { id: 'version-1.0.1/foo/bar', @@ -475,6 +523,8 @@ describe('versioned site', () => { }); test('next doc slugs', async () => { + const {currentVersionTestUtils} = await loadSite(); + await currentVersionTestUtils.testSlug( path.join('slugs', 'absoluteSlug.md'), '/docs/next/absoluteSlug', @@ -494,6 +544,8 @@ describe('versioned site', () => { }); test('versioned doc slugs', async () => { + const {versionWithSlugsTestUtils} = await loadSite(); + await versionWithSlugsTestUtils.testSlug( path.join('rootAbsoluteSlug.md'), '/docs/withSlugs/rootAbsoluteSlug', @@ -528,4 +580,33 @@ describe('versioned site', () => { '/docs/withSlugs/tryToEscapeSlug', ); }); + + test('translated doc with editUrl', async () => { + const {siteDir, context, options, version100} = await loadSite(); + + const testUtilsLocal = createTestUtils({ + siteDir, + context, + options: { + ...options, + editUrl: 'https://github.com/facebook/docusaurus/edit/master/website', + }, + versionMetadata: version100, + }); + + await testUtilsLocal.testMeta(path.join('hello.md'), { + id: 'version-1.0.0/hello', + unversionedId: 'hello', + isDocsHomePage: false, + permalink: '/docs/1.0.0/hello', + slug: '/hello', + title: 'hello', + description: 'Hello 1.0.0 ! (translated)', + version: '1.0.0', + source: + '@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md', + editUrl: + 'https://github.com/facebook/docusaurus/edit/master/website/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md', + }); + }); }); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts index fa1b1b71fa2b..baf1a971e0e2 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/index.test.ts @@ -106,7 +106,7 @@ Entries created: test('site with wrong sidebar file', async () => { const siteDir = path.join(__dirname, '__fixtures__', 'simple-site'); - const context = loadContext(siteDir); + const context = await loadContext(siteDir); const sidebarPath = path.join(siteDir, 'wrong-sidebars.json'); const plugin = pluginContentDocs( context, @@ -119,9 +119,9 @@ test('site with wrong sidebar file', async () => { describe('empty/no docs website', () => { const siteDir = path.join(__dirname, '__fixtures__', 'empty-site'); - const context = loadContext(siteDir); test('no files in docs folder', async () => { + const context = await loadContext(siteDir); await fs.ensureDir(path.join(siteDir, 'docs')); const plugin = pluginContentDocs( context, @@ -135,6 +135,7 @@ describe('empty/no docs website', () => { }); test('docs folder does not exist', async () => { + const context = await loadContext(siteDir); expect(() => pluginContentDocs( context, @@ -149,20 +150,25 @@ describe('empty/no docs website', () => { }); describe('simple website', () => { - const siteDir = path.join(__dirname, '__fixtures__', 'simple-site'); - const context = loadContext(siteDir); - const sidebarPath = path.join(siteDir, 'sidebars.json'); - const plugin = pluginContentDocs( - context, - normalizePluginOptions(OptionsSchema, { - path: 'docs', - sidebarPath, - homePageId: 'hello', - }), - ); - const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + async function loadSite() { + const siteDir = path.join(__dirname, '__fixtures__', 'simple-site'); + const context = await loadContext(siteDir); + const sidebarPath = path.join(siteDir, 'sidebars.json'); + const plugin = pluginContentDocs( + context, + normalizePluginOptions(OptionsSchema, { + path: 'docs', + sidebarPath, + homePageId: 'hello', + }), + ); + const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + + return {siteDir, context, sidebarPath, plugin, pluginContentDir}; + } - test('extendCli - docsVersion', () => { + test('extendCli - docsVersion', async () => { + const {siteDir, sidebarPath, plugin} = await loadSite(); const mock = jest .spyOn(cliDocs, 'cliDocsVersionCommand') .mockImplementation(); @@ -178,7 +184,9 @@ describe('simple website', () => { mock.mockRestore(); }); - test('getPathToWatch', () => { + test('getPathToWatch', async () => { + const {siteDir, plugin} = await loadSite(); + const pathToWatch = plugin.getPathsToWatch!(); const matchPattern = pathToWatch.map((filepath) => posixPath(path.relative(siteDir, filepath)), @@ -187,6 +195,7 @@ describe('simple website', () => { expect(matchPattern).toMatchInlineSnapshot(` Array [ "sidebars.json", + "i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}", "docs/**/*.{md,mdx}", ] `); @@ -203,6 +212,8 @@ describe('simple website', () => { }); test('configureWebpack', async () => { + const {plugin} = await loadSite(); + const config = applyConfigureWebpack( plugin.configureWebpack, { @@ -219,6 +230,7 @@ describe('simple website', () => { }); test('content', async () => { + const {siteDir, plugin, pluginContentDir} = await loadSite(); const content = await plugin.loadContent!(); expect(content.loadedVersions.length).toEqual(1); const [currentVersion] = content.loadedVersions; @@ -287,22 +299,32 @@ describe('simple website', () => { }); describe('versioned website', () => { - const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); - const context = loadContext(siteDir); - const sidebarPath = path.join(siteDir, 'sidebars.json'); - const routeBasePath = 'docs'; - const plugin = pluginContentDocs( - context, - normalizePluginOptions(OptionsSchema, { + async function loadSite() { + const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); + const context = await loadContext(siteDir); + const sidebarPath = path.join(siteDir, 'sidebars.json'); + const routeBasePath = 'docs'; + const plugin = pluginContentDocs( + context, + normalizePluginOptions(OptionsSchema, { + routeBasePath, + sidebarPath, + homePageId: 'hello', + }), + ); + const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + return { + siteDir, + context, routeBasePath, sidebarPath, - homePageId: 'hello', - }), - ); - - const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + plugin, + pluginContentDir, + }; + } - test('extendCli - docsVersion', () => { + test('extendCli - docsVersion', async () => { + const {siteDir, routeBasePath, sidebarPath, plugin} = await loadSite(); const mock = jest .spyOn(cliDocs, 'cliDocsVersionCommand') .mockImplementation(); @@ -318,7 +340,8 @@ describe('versioned website', () => { mock.mockRestore(); }); - test('getPathToWatch', () => { + test('getPathToWatch', async () => { + const {siteDir, plugin} = await loadSite(); const pathToWatch = plugin.getPathsToWatch!(); const matchPattern = pathToWatch.map((filepath) => posixPath(path.relative(siteDir, filepath)), @@ -327,12 +350,16 @@ describe('versioned website', () => { expect(matchPattern).toMatchInlineSnapshot(` Array [ "sidebars.json", + "i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}", "docs/**/*.{md,mdx}", "versioned_sidebars/version-1.0.1-sidebars.json", + "i18n/en/docusaurus-plugin-content-docs/version-1.0.1/**/*.{md,mdx}", "versioned_docs/version-1.0.1/**/*.{md,mdx}", "versioned_sidebars/version-1.0.0-sidebars.json", + "i18n/en/docusaurus-plugin-content-docs/version-1.0.0/**/*.{md,mdx}", "versioned_docs/version-1.0.0/**/*.{md,mdx}", "versioned_sidebars/version-withSlugs-sidebars.json", + "i18n/en/docusaurus-plugin-content-docs/version-withSlugs/**/*.{md,mdx}", "versioned_docs/version-withSlugs/**/*.{md,mdx}", ] `); @@ -369,6 +396,7 @@ describe('versioned website', () => { }); test('content', async () => { + const {siteDir, plugin, pluginContentDir} = await loadSite(); const content = await plugin.loadContent!(); expect(content.loadedVersions.length).toEqual(4); const [ @@ -499,23 +527,41 @@ describe('versioned website', () => { }); describe('versioned website (community)', () => { - const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); - const context = loadContext(siteDir); - const sidebarPath = path.join(siteDir, 'community_sidebars.json'); - const routeBasePath = 'community'; - const pluginId = 'community'; - const plugin = pluginContentDocs( - context, - normalizePluginOptions(OptionsSchema, { - id: 'community', - path: 'community', + async function loadSite() { + const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site'); + const context = await loadContext(siteDir); + const sidebarPath = path.join(siteDir, 'community_sidebars.json'); + const routeBasePath = 'community'; + const pluginId = 'community'; + const plugin = pluginContentDocs( + context, + normalizePluginOptions(OptionsSchema, { + id: 'community', + path: 'community', + routeBasePath, + sidebarPath, + }), + ); + const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); + return { + siteDir, + context, routeBasePath, sidebarPath, - }), - ); - const pluginContentDir = path.join(context.generatedFilesDir, plugin.name); - - test('extendCli - docsVersion', () => { + pluginId, + plugin, + pluginContentDir, + }; + } + + test('extendCli - docsVersion', async () => { + const { + siteDir, + routeBasePath, + sidebarPath, + pluginId, + plugin, + } = await loadSite(); const mock = jest .spyOn(cliDocs, 'cliDocsVersionCommand') .mockImplementation(); @@ -531,7 +577,8 @@ describe('versioned website (community)', () => { mock.mockRestore(); }); - test('getPathToWatch', () => { + test('getPathToWatch', async () => { + const {siteDir, plugin} = await loadSite(); const pathToWatch = plugin.getPathsToWatch!(); const matchPattern = pathToWatch.map((filepath) => posixPath(path.relative(siteDir, filepath)), @@ -540,8 +587,10 @@ describe('versioned website (community)', () => { expect(matchPattern).toMatchInlineSnapshot(` Array [ "community_sidebars.json", + "i18n/en/docusaurus-plugin-content-docs-community/current/**/*.{md,mdx}", "community/**/*.{md,mdx}", "community_versioned_sidebars/version-1.0.0-sidebars.json", + "i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/**/*.{md,mdx}", "community_versioned_docs/version-1.0.0/**/*.{md,mdx}", ] `); @@ -568,6 +617,7 @@ describe('versioned website (community)', () => { }); test('content', async () => { + const {siteDir, plugin, pluginContentDir} = await loadSite(); const content = await plugin.loadContent!(); expect(content.loadedVersions.length).toEqual(2); const [currentVersion, version100] = content.loadedVersions; @@ -579,13 +629,17 @@ describe('versioned website (community)', () => { isDocsHomePage: false, permalink: '/community/next/team', slug: '/team', + /* source: path.join( '@site', path.relative(siteDir, currentVersion.docsDirPath), 'team.md', ), - title: 'team', - description: 'Team current version', + */ + source: + '@site/i18n/en/docusaurus-plugin-content-docs-community/current/team.md', + title: 'Team title translated', + description: 'Team current version (translated)', version: 'current', sidebar: 'community', }); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts index d46917e42a4b..e7af5a7e7ae5 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebars.test.ts @@ -11,6 +11,9 @@ import { collectSidebarDocItems, collectSidebarsDocIds, createSidebarsUtils, + collectSidebarCategories, + collectSidebarLinks, + transformSidebarItems, } from '../sidebars'; import {Sidebar, Sidebars} from '../types'; @@ -163,7 +166,7 @@ describe('loadSidebars', () => { }); describe('collectSidebarDocItems', () => { - test('can collect recursively', async () => { + test('can collect docs', async () => { const sidebar: Sidebar = [ { type: 'category', @@ -213,7 +216,96 @@ describe('collectSidebarDocItems', () => { }); }); -describe('collectSidebarsDocItems', () => { +describe('collectSidebarCategories', () => { + test('can collect categories', async () => { + const sidebar: Sidebar = [ + { + type: 'category', + collapsed: false, + label: 'Category1', + items: [ + { + type: 'category', + collapsed: false, + label: 'Subcategory 1', + items: [{type: 'doc', id: 'doc1'}], + }, + { + type: 'category', + collapsed: false, + label: 'Subcategory 2', + items: [ + {type: 'doc', id: 'doc2'}, + { + type: 'category', + collapsed: false, + label: 'Sub sub category 1', + items: [{type: 'doc', id: 'doc3'}], + }, + ], + }, + ], + }, + { + type: 'category', + collapsed: false, + label: 'Category2', + items: [ + {type: 'doc', id: 'doc4'}, + {type: 'doc', id: 'doc5'}, + ], + }, + ]; + + expect( + collectSidebarCategories(sidebar).map((category) => category.label), + ).toEqual([ + 'Category1', + 'Subcategory 1', + 'Subcategory 2', + 'Sub sub category 1', + 'Category2', + ]); + }); +}); + +describe('collectSidebarLinks', () => { + test('can collect links', async () => { + const sidebar: Sidebar = [ + { + type: 'category', + collapsed: false, + label: 'Category1', + items: [ + { + type: 'link', + href: 'https://google.com', + label: 'Google', + }, + { + type: 'category', + collapsed: false, + label: 'Subcategory 2', + items: [ + { + type: 'link', + href: 'https://facebook.com', + label: 'Facebook', + }, + ], + }, + ], + }, + ]; + + expect(collectSidebarLinks(sidebar).map((link) => link.href)).toEqual([ + 'https://google.com', + 'https://facebook.com', + ]); + }); +}); + +describe('collectSidebarsDocIds', () => { test('can collect sidebars doc items', async () => { const sidebar1: Sidebar = [ { @@ -256,6 +348,95 @@ describe('collectSidebarsDocItems', () => { }); }); +describe('transformSidebarItems', () => { + test('can transform sidebar items', async () => { + const sidebar: Sidebar = [ + { + type: 'category', + collapsed: false, + label: 'Category1', + items: [ + { + type: 'category', + collapsed: false, + label: 'Subcategory 1', + items: [{type: 'doc', id: 'doc1'}], + }, + { + type: 'category', + collapsed: false, + label: 'Subcategory 2', + items: [ + {type: 'doc', id: 'doc2'}, + { + type: 'category', + collapsed: false, + label: 'Sub sub category 1', + items: [{type: 'doc', id: 'doc3'}], + }, + ], + }, + ], + }, + { + type: 'category', + collapsed: false, + label: 'Category2', + items: [ + {type: 'doc', id: 'doc4'}, + {type: 'doc', id: 'doc5'}, + ], + }, + ]; + + expect( + transformSidebarItems(sidebar, (item) => { + if (item.type === 'category') { + return {...item, label: `MODIFIED LABEL: ${item.label}`}; + } + return item; + }), + ).toEqual([ + { + type: 'category', + collapsed: false, + label: 'MODIFIED LABEL: Category1', + items: [ + { + type: 'category', + collapsed: false, + label: 'MODIFIED LABEL: Subcategory 1', + items: [{type: 'doc', id: 'doc1'}], + }, + { + type: 'category', + collapsed: false, + label: 'MODIFIED LABEL: Subcategory 2', + items: [ + {type: 'doc', id: 'doc2'}, + { + type: 'category', + collapsed: false, + label: 'MODIFIED LABEL: Sub sub category 1', + items: [{type: 'doc', id: 'doc3'}], + }, + ], + }, + ], + }, + { + type: 'category', + collapsed: false, + label: 'MODIFIED LABEL: Category2', + items: [ + {type: 'doc', id: 'doc4'}, + {type: 'doc', id: 'doc5'}, + ], + }, + ]); + }); +}); + describe('createSidebarsUtils', () => { const sidebar1: Sidebar = [ { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts new file mode 100644 index 000000000000..a3be1c6c0bf3 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/translations.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {LoadedContent, DocMetadata, LoadedVersion} from '../types'; +import {CURRENT_VERSION_NAME} from '../constants'; +import { + getLoadedContentTranslationFiles, + translateLoadedContent, +} from '../translations'; +import {updateTranslationFileMessages} from '@docusaurus/utils'; + +function createSampleDoc(doc: Pick): DocMetadata { + return { + editUrl: 'any', + isDocsHomePage: false, + lastUpdatedAt: 0, + lastUpdatedBy: 'any', + next: undefined, + previous: undefined, + permalink: 'any', + slug: 'any', + source: 'any', + unversionedId: 'any', + version: 'any', + title: `${doc.id} title`, + sidebar_label: `${doc.id} title`, + description: `${doc.id} description`, + ...doc, + }; +} + +function createSampleVersion( + version: Pick, +): LoadedVersion { + return { + versionLabel: `${version.versionName} label`, + versionPath: '/docs/', + mainDocId: '', + permalinkToSidebar: {}, + routePriority: undefined, + sidebarFilePath: 'any', + isLast: true, + docsDirPath: 'any', + docsDirPathLocalized: 'any', + docs: [ + createSampleDoc({ + id: 'doc1', + }), + createSampleDoc({ + id: 'doc2', + }), + createSampleDoc({ + id: 'doc3', + }), + createSampleDoc({ + id: 'doc4', + }), + createSampleDoc({ + id: 'doc5', + }), + ], + sidebars: { + docs: [ + { + type: 'category', + label: 'Getting started', + collapsed: false, + items: [ + { + type: 'doc', + id: 'doc1', + }, + { + type: 'doc', + id: 'doc2', + }, + { + type: 'link', + label: 'Link label', + href: 'https://facebook.com', + }, + { + type: 'ref', + id: 'doc1', + }, + ], + }, + { + type: 'doc', + id: 'doc3', + }, + ], + otherSidebar: [ + { + type: 'doc', + id: 'doc4', + }, + { + type: 'doc', + id: 'doc5', + }, + ], + }, + ...version, + }; +} + +const SampleLoadedContent: LoadedContent = { + loadedVersions: [ + createSampleVersion({ + versionName: CURRENT_VERSION_NAME, + }), + createSampleVersion({ + versionName: '2.0.0', + }), + createSampleVersion({ + versionName: '1.0.0', + }), + ], +}; + +function getSampleTranslationFiles() { + return getLoadedContentTranslationFiles(SampleLoadedContent); +} +function getSampleTranslationFilesTranslated() { + const translationFiles = getSampleTranslationFiles(); + return translationFiles.map((translationFile) => + updateTranslationFileMessages( + translationFile, + (message) => `${message} (translated)`, + ), + ); +} + +describe('getLoadedContentTranslationFiles', () => { + test('should return translation files matching snapshot', async () => { + expect(getSampleTranslationFiles()).toMatchSnapshot(); + }); +}); + +describe('translateLoadedContent', () => { + test('should not translate anything if translation files are untranslated', () => { + const translationFiles = getSampleTranslationFiles(); + expect( + translateLoadedContent(SampleLoadedContent, translationFiles), + ).toEqual(SampleLoadedContent); + }); + + test('should return translated loaded content matching snapshot', () => { + const translationFiles = getSampleTranslationFilesTranslated(); + expect( + translateLoadedContent(SampleLoadedContent, translationFiles), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts index db8bbd2086a2..2d973cc57cef 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/versions.test.ts @@ -14,7 +14,14 @@ import { } from '../versions'; import {DEFAULT_OPTIONS} from '../options'; import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants'; -import {VersionMetadata} from '../types'; +import {PluginOptions, VersionMetadata} from '../types'; +import {I18n} from '@docusaurus/types'; + +const DefaultI18N: I18n = { + currentLocale: 'en', + locales: ['en'], + defaultLocale: 'en', +}; describe('version paths', () => { test('getVersionedDocsDirPath', () => { @@ -46,29 +53,39 @@ describe('version paths', () => { }); describe('simple site', () => { - const simpleSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'simple-site'), - ); - const defaultOptions = { - id: DEFAULT_PLUGIN_ID, - ...DEFAULT_OPTIONS, - }; - const defaultContext = { - siteDir: simpleSiteDir, - baseUrl: '/', - }; - - const vCurrent: VersionMetadata = { - docsDirPath: path.join(simpleSiteDir, 'docs'), - isLast: true, - routePriority: -1, - sidebarFilePath: path.join(simpleSiteDir, 'sidebars.json'), - versionLabel: 'Next', - versionName: 'current', - versionPath: '/docs', - }; - - test('readVersionsMetadata simple site', () => { + async function loadSite() { + const simpleSiteDir = path.resolve( + path.join(__dirname, '__fixtures__', 'simple-site'), + ); + const defaultOptions: PluginOptions = { + id: DEFAULT_PLUGIN_ID, + ...DEFAULT_OPTIONS, + }; + const defaultContext = { + siteDir: simpleSiteDir, + baseUrl: '/', + i18n: DefaultI18N, + }; + + const vCurrent: VersionMetadata = { + docsDirPath: path.join(simpleSiteDir, 'docs'), + docsDirPathLocalized: path.join( + simpleSiteDir, + 'i18n/en/docusaurus-plugin-content-docs/current', + ), + isLast: true, + routePriority: -1, + sidebarFilePath: path.join(simpleSiteDir, 'sidebars.json'), + versionLabel: 'Next', + versionName: 'current', + versionPath: '/docs', + }; + return {simpleSiteDir, defaultOptions, defaultContext, vCurrent}; + } + + test('readVersionsMetadata simple site', async () => { + const {defaultOptions, defaultContext, vCurrent} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: defaultOptions, context: defaultContext, @@ -77,7 +94,9 @@ describe('simple site', () => { expect(versionsMetadata).toEqual([vCurrent]); }); - test('readVersionsMetadata simple site with base url', () => { + test('readVersionsMetadata simple site with base url', async () => { + const {defaultOptions, defaultContext, vCurrent} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: defaultOptions, context: { @@ -94,7 +113,9 @@ describe('simple site', () => { ]); }); - test('readVersionsMetadata simple site with current version config', () => { + test('readVersionsMetadata simple site with current version config', async () => { + const {defaultOptions, defaultContext, vCurrent} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: { ...defaultOptions, @@ -121,7 +142,9 @@ describe('simple site', () => { ]); }); - test('readVersionsMetadata simple site with unknown lastVersion should throw', () => { + test('readVersionsMetadata simple site with unknown lastVersion should throw', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: {...defaultOptions, lastVersion: 'unknownVersionName'}, @@ -132,7 +155,9 @@ describe('simple site', () => { ); }); - test('readVersionsMetadata simple site with unknown version configurations should throw', () => { + test('readVersionsMetadata simple site with unknown version configurations should throw', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { @@ -150,7 +175,9 @@ describe('simple site', () => { ); }); - test('readVersionsMetadata simple site with disableVersioning while single version should throw', () => { + test('readVersionsMetadata simple site with disableVersioning while single version should throw', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: {...defaultOptions, disableVersioning: true}, @@ -161,7 +188,9 @@ describe('simple site', () => { ); }); - test('readVersionsMetadata simple site without including current version should throw', () => { + test('readVersionsMetadata simple site without including current version should throw', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: {...defaultOptions, includeCurrentVersion: false}, @@ -174,71 +203,109 @@ describe('simple site', () => { }); describe('versioned site, pluginId=default', () => { - const versionedSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'versioned-site'), - ); - const defaultOptions = { - id: DEFAULT_PLUGIN_ID, - ...DEFAULT_OPTIONS, - }; - const defaultContext = { - siteDir: versionedSiteDir, - baseUrl: '/', - }; - - const vCurrent: VersionMetadata = { - docsDirPath: path.join(versionedSiteDir, 'docs'), - isLast: false, - routePriority: undefined, - sidebarFilePath: path.join(versionedSiteDir, 'sidebars.json'), - versionLabel: 'Next', - versionName: 'current', - versionPath: '/docs/next', - }; - - const v101: VersionMetadata = { - docsDirPath: path.join(versionedSiteDir, 'versioned_docs/version-1.0.1'), - isLast: true, - routePriority: -1, - sidebarFilePath: path.join( - versionedSiteDir, - 'versioned_sidebars/version-1.0.1-sidebars.json', - ), - versionLabel: '1.0.1', - versionName: '1.0.1', - versionPath: '/docs', - }; - - const v100: VersionMetadata = { - docsDirPath: path.join(versionedSiteDir, 'versioned_docs/version-1.0.0'), - isLast: false, - routePriority: undefined, - sidebarFilePath: path.join( - versionedSiteDir, - 'versioned_sidebars/version-1.0.0-sidebars.json', - ), - versionLabel: '1.0.0', - versionName: '1.0.0', - versionPath: '/docs/1.0.0', - }; - - const vwithSlugs: VersionMetadata = { - docsDirPath: path.join( - versionedSiteDir, - 'versioned_docs/version-withSlugs', - ), - isLast: false, - routePriority: undefined, - sidebarFilePath: path.join( + async function loadSite() { + const versionedSiteDir = path.resolve( + path.join(__dirname, '__fixtures__', 'versioned-site'), + ); + const defaultOptions: PluginOptions = { + id: DEFAULT_PLUGIN_ID, + ...DEFAULT_OPTIONS, + }; + const defaultContext = { + siteDir: versionedSiteDir, + baseUrl: '/', + i18n: DefaultI18N, + }; + + const vCurrent: VersionMetadata = { + docsDirPath: path.join(versionedSiteDir, 'docs'), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs/current', + ), + isLast: false, + routePriority: undefined, + sidebarFilePath: path.join(versionedSiteDir, 'sidebars.json'), + versionLabel: 'Next', + versionName: 'current', + versionPath: '/docs/next', + }; + + const v101: VersionMetadata = { + docsDirPath: path.join(versionedSiteDir, 'versioned_docs/version-1.0.1'), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs/version-1.0.1', + ), + isLast: true, + routePriority: -1, + sidebarFilePath: path.join( + versionedSiteDir, + 'versioned_sidebars/version-1.0.1-sidebars.json', + ), + versionLabel: '1.0.1', + versionName: '1.0.1', + versionPath: '/docs', + }; + + const v100: VersionMetadata = { + docsDirPath: path.join(versionedSiteDir, 'versioned_docs/version-1.0.0'), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs/version-1.0.0', + ), + isLast: false, + routePriority: undefined, + sidebarFilePath: path.join( + versionedSiteDir, + 'versioned_sidebars/version-1.0.0-sidebars.json', + ), + versionLabel: '1.0.0', + versionName: '1.0.0', + versionPath: '/docs/1.0.0', + }; + + const vwithSlugs: VersionMetadata = { + docsDirPath: path.join( + versionedSiteDir, + 'versioned_docs/version-withSlugs', + ), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs/version-withSlugs', + ), + isLast: false, + routePriority: undefined, + sidebarFilePath: path.join( + versionedSiteDir, + 'versioned_sidebars/version-withSlugs-sidebars.json', + ), + versionLabel: 'withSlugs', + versionName: 'withSlugs', + versionPath: '/docs/withSlugs', + }; + + return { versionedSiteDir, - 'versioned_sidebars/version-withSlugs-sidebars.json', - ), - versionLabel: 'withSlugs', - versionName: 'withSlugs', - versionPath: '/docs/withSlugs', - }; - - test('readVersionsMetadata versioned site', () => { + defaultOptions, + defaultContext, + vCurrent, + v101, + v100, + vwithSlugs, + }; + } + + test('readVersionsMetadata versioned site', async () => { + const { + defaultOptions, + defaultContext, + vCurrent, + v101, + v100, + vwithSlugs, + } = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: defaultOptions, context: defaultContext, @@ -247,7 +314,15 @@ describe('versioned site, pluginId=default', () => { expect(versionsMetadata).toEqual([vCurrent, v101, v100, vwithSlugs]); }); - test('readVersionsMetadata versioned site with includeCurrentVersion=false', () => { + test('readVersionsMetadata versioned site with includeCurrentVersion=false', async () => { + const { + defaultOptions, + defaultContext, + v101, + v100, + vwithSlugs, + } = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: {...defaultOptions, includeCurrentVersion: false}, context: defaultContext, @@ -261,7 +336,16 @@ describe('versioned site, pluginId=default', () => { ]); }); - test('readVersionsMetadata versioned site with version options', () => { + test('readVersionsMetadata versioned site with version options', async () => { + const { + defaultOptions, + defaultContext, + vCurrent, + v101, + v100, + vwithSlugs, + } = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: { ...defaultOptions, @@ -297,7 +381,9 @@ describe('versioned site, pluginId=default', () => { ]); }); - test('readVersionsMetadata versioned site with onlyIncludeVersions option', () => { + test('readVersionsMetadata versioned site with onlyIncludeVersions option', async () => { + const {defaultOptions, defaultContext, v101, vwithSlugs} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: { ...defaultOptions, @@ -310,7 +396,9 @@ describe('versioned site, pluginId=default', () => { expect(versionsMetadata).toEqual([v101, vwithSlugs]); }); - test('readVersionsMetadata versioned site with disableVersioning', () => { + test('readVersionsMetadata versioned site with disableVersioning', async () => { + const {defaultOptions, defaultContext, vCurrent} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: {...defaultOptions, disableVersioning: true}, context: defaultContext, @@ -321,7 +409,9 @@ describe('versioned site, pluginId=default', () => { ]); }); - test('readVersionsMetadata versioned site with all versions disabled', () => { + test('readVersionsMetadata versioned site with all versions disabled', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { @@ -336,7 +426,9 @@ describe('versioned site, pluginId=default', () => { ); }); - test('readVersionsMetadata versioned site with empty onlyIncludeVersions', () => { + test('readVersionsMetadata versioned site with empty onlyIncludeVersions', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { @@ -350,7 +442,9 @@ describe('versioned site, pluginId=default', () => { ); }); - test('readVersionsMetadata versioned site with unknown versions in onlyIncludeVersions', () => { + test('readVersionsMetadata versioned site with unknown versions in onlyIncludeVersions', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { @@ -364,7 +458,9 @@ describe('versioned site, pluginId=default', () => { ); }); - test('readVersionsMetadata versioned site with lastVersion not in onlyIncludeVersions', () => { + test('readVersionsMetadata versioned site with lastVersion not in onlyIncludeVersions', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { @@ -379,7 +475,9 @@ describe('versioned site, pluginId=default', () => { ); }); - test('readVersionsMetadata versioned site with invalid versions.json file', () => { + test('readVersionsMetadata versioned site with invalid versions.json file', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + const mock = jest.spyOn(JSON, 'parse').mockImplementationOnce(() => { return { invalid: 'json', @@ -399,47 +497,62 @@ describe('versioned site, pluginId=default', () => { }); describe('versioned site, pluginId=community', () => { - const versionedSiteDir = path.resolve( - path.join(__dirname, '__fixtures__', 'versioned-site'), - ); - const defaultOptions = { - ...DEFAULT_OPTIONS, - id: 'community', - path: 'community', - routeBasePath: 'communityBasePath', - }; - const defaultContext = { - siteDir: versionedSiteDir, - baseUrl: '/', - }; - - const vCurrent: VersionMetadata = { - docsDirPath: path.join(versionedSiteDir, 'community'), - isLast: false, - routePriority: undefined, - sidebarFilePath: path.join(versionedSiteDir, 'sidebars.json'), - versionLabel: 'Next', - versionName: 'current', - versionPath: '/communityBasePath/next', - }; - - const v100: VersionMetadata = { - docsDirPath: path.join( - versionedSiteDir, - 'community_versioned_docs/version-1.0.0', - ), - isLast: true, - routePriority: -1, - sidebarFilePath: path.join( - versionedSiteDir, - 'community_versioned_sidebars/version-1.0.0-sidebars.json', - ), - versionLabel: '1.0.0', - versionName: '1.0.0', - versionPath: '/communityBasePath', - }; - - test('readVersionsMetadata versioned site (community)', () => { + async function loadSite() { + const versionedSiteDir = path.resolve( + path.join(__dirname, '__fixtures__', 'versioned-site'), + ); + const defaultOptions: PluginOptions = { + ...DEFAULT_OPTIONS, + id: 'community', + path: 'community', + routeBasePath: 'communityBasePath', + }; + const defaultContext = { + siteDir: versionedSiteDir, + baseUrl: '/', + i18n: DefaultI18N, + }; + + const vCurrent: VersionMetadata = { + docsDirPath: path.join(versionedSiteDir, 'community'), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs-community/current', + ), + isLast: false, + routePriority: undefined, + sidebarFilePath: path.join(versionedSiteDir, 'sidebars.json'), + versionLabel: 'Next', + versionName: 'current', + versionPath: '/communityBasePath/next', + }; + + const v100: VersionMetadata = { + docsDirPath: path.join( + versionedSiteDir, + 'community_versioned_docs/version-1.0.0', + ), + docsDirPathLocalized: path.join( + versionedSiteDir, + 'i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0', + ), + isLast: true, + routePriority: -1, + sidebarFilePath: path.join( + versionedSiteDir, + 'community_versioned_sidebars/version-1.0.0-sidebars.json', + ), + versionLabel: '1.0.0', + versionName: '1.0.0', + versionPath: '/communityBasePath', + }; + + return {versionedSiteDir, defaultOptions, defaultContext, vCurrent, v100}; + } + + test('readVersionsMetadata versioned site (community)', async () => { + const {defaultOptions, defaultContext, vCurrent, v100} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: defaultOptions, context: defaultContext, @@ -448,7 +561,9 @@ describe('versioned site, pluginId=community', () => { expect(versionsMetadata).toEqual([vCurrent, v100]); }); - test('readVersionsMetadata versioned site (community) with includeCurrentVersion=false', () => { + test('readVersionsMetadata versioned site (community) with includeCurrentVersion=false', async () => { + const {defaultOptions, defaultContext, v100} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: {...defaultOptions, includeCurrentVersion: false}, context: defaultContext, @@ -460,7 +575,9 @@ describe('versioned site, pluginId=community', () => { ]); }); - test('readVersionsMetadata versioned site (community) with disableVersioning', () => { + test('readVersionsMetadata versioned site (community) with disableVersioning', async () => { + const {defaultOptions, defaultContext, vCurrent} = await loadSite(); + const versionsMetadata = readVersionsMetadata({ options: {...defaultOptions, disableVersioning: true}, context: defaultContext, @@ -476,7 +593,9 @@ describe('versioned site, pluginId=community', () => { ]); }); - test('readVersionsMetadata versioned site (community) with all versions disabled', () => { + test('readVersionsMetadata versioned site (community) with all versions disabled', async () => { + const {defaultOptions, defaultContext} = await loadSite(); + expect(() => readVersionsMetadata({ options: { diff --git a/packages/docusaurus-plugin-content-docs/src/docs.ts b/packages/docusaurus-plugin-content-docs/src/docs.ts index f6c931b955ac..0439b8e6dc52 100644 --- a/packages/docusaurus-plugin-content-docs/src/docs.ts +++ b/packages/docusaurus-plugin-content-docs/src/docs.ts @@ -12,6 +12,7 @@ import { normalizeUrl, getEditUrl, parseMarkdownString, + getFolderContainingFile, } from '@docusaurus/utils'; import {LoadContext} from '@docusaurus/types'; @@ -27,6 +28,7 @@ import { import getSlug from './slug'; import {CURRENT_VERSION_NAME} from './constants'; import globby from 'globby'; +import {getDocsDirPaths} from './versions'; type LastUpdateOptions = Pick< PluginOptions, @@ -61,16 +63,25 @@ async function readLastUpdateData( } export async function readDocFile( - docsDirPath: string, + versionMetadata: Pick< + VersionMetadata, + 'docsDirPath' | 'docsDirPathLocalized' + >, source: string, options: LastUpdateOptions, ): Promise { - const filePath = path.join(docsDirPath, source); + const folderPath = await getFolderContainingFile( + getDocsDirPaths(versionMetadata), + source, + ); + + const filePath = path.join(folderPath, source); + const [content, lastUpdate] = await Promise.all([ fs.readFile(filePath, 'utf-8'), readLastUpdateData(filePath, options), ]); - return {source, content, lastUpdate}; + return {source, content, lastUpdate, filePath}; } export async function readVersionDocs( @@ -84,9 +95,7 @@ export async function readVersionDocs( cwd: versionMetadata.docsDirPath, }); return Promise.all( - sources.map((source) => - readDocFile(versionMetadata.docsDirPath, source, options), - ), + sources.map((source) => readDocFile(versionMetadata, source, options)), ); } @@ -101,10 +110,9 @@ export function processDocMetadata({ context: LoadContext; options: MetadataOptions; }): DocMetadataBase { - const {source, content, lastUpdate} = docFile; + const {source, content, lastUpdate, filePath} = docFile; const {editUrl, homePageId} = options; const {siteDir} = context; - const filePath = path.join(versionMetadata.docsDirPath, source); // ex: api/myDoc -> api // ex: myDoc -> . diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index a36828de7a0d..b84f59b832f0 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -21,7 +21,7 @@ import {LoadContext, Plugin, RouteConfig} from '@docusaurus/types'; import {loadSidebars, createSidebarsUtils} from './sidebars'; import {readVersionDocs, processDocMetadata} from './docs'; -import {readVersionsMetadata} from './versions'; +import {getDocsDirPaths, readVersionsMetadata} from './versions'; import { PluginOptions, @@ -44,6 +44,10 @@ import {OptionsSchema} from './options'; import {flatten, keyBy, compact} from 'lodash'; import {toGlobalDataVersion} from './globalData'; import {toVersionMetadataProp} from './props'; +import { + translateLoadedContent, + getLoadedContentTranslationFiles, +} from './translations'; export default function pluginContentDocs( context: LoadContext, @@ -99,6 +103,10 @@ export default function pluginContentDocs( }); }, + async getTranslationFiles() { + return getLoadedContentTranslationFiles(await this.loadContent!()); + }, + getClientModules() { const modules = []; if (options.admonitions) { @@ -111,8 +119,12 @@ export default function pluginContentDocs( function getVersionPathsToWatch(version: VersionMetadata): string[] { return [ version.sidebarFilePath, - ...options.include.map( - (pattern) => `${version.docsDirPath}/${pattern}`, + ...flatten( + options.include.map((pattern) => + getDocsDirPaths(version).map( + (docsDirPath) => `${docsDirPath}/${pattern}`, + ), + ), ), ]; } @@ -235,6 +247,10 @@ export default function pluginContentDocs( }; }, + translateContent({content, translationFiles}) { + return translateLoadedContent(content, translationFiles); + }, + async contentLoaded({content, actions}) { const {loadedVersions} = content; const {docLayoutComponent, docItemComponent} = options; @@ -318,7 +334,6 @@ export default function pluginContentDocs( if (siteConfig.onBrokenMarkdownLinks === 'ignore') { return; } - reportMessage( `Docs markdown link couldn't be resolved: (${brokenMarkdownLink.link}) in ${brokenMarkdownLink.filePath} for version ${brokenMarkdownLink.version.versionName}`, siteConfig.onBrokenMarkdownLinks, @@ -329,7 +344,7 @@ export default function pluginContentDocs( function createMDXLoaderRule(): RuleSetRule { return { test: /(\.mdx?)$/, - include: versionsMetadata.map((vmd) => vmd.docsDirPath), + include: flatten(versionsMetadata.map(getDocsDirPaths)), use: compact([ getCacheLoader(isServer), getBabelLoader(isServer), diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc-localized.md b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc-localized.md new file mode 100644 index 000000000000..63e38da76c0a --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc-localized.md @@ -0,0 +1 @@ +### localized doc diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc2.md b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc2.md index 4c1e57697850..e41f1fe6bdb2 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc2.md +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/docs/doc2.md @@ -8,3 +8,5 @@ - [doc1](doc1.md) - [doc2](./doc2.md) + +- [doc-localized](./doc-localized.md) diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap index d90e6c46a733..95982ca782e5 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__snapshots__/linkify.test.ts.snap @@ -35,6 +35,8 @@ exports[`transform to correct links 1`] = ` - [doc1](/docs/doc1) - [doc2](/docs/doc2) + +- [doc-localized](/fr/doc-localized) " `; diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts index 1e12fc36dfba..7fe3e179da37 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/linkify.test.ts @@ -16,15 +16,21 @@ import { } from '../../types'; import {VERSIONED_DOCS_DIR, CURRENT_VERSION_NAME} from '../../constants'; -function createFakeVersion( - versionName: string, - docsDirPath: string, -): VersionMetadata { +function createFakeVersion({ + versionName, + docsDirPath, + docsDirPathLocalized, +}: { + versionName: string; + docsDirPath: string; + docsDirPathLocalized: string; +}): VersionMetadata { return { versionName, versionLabel: 'Any', versionPath: 'any', docsDirPath, + docsDirPathLocalized, sidebarFilePath: 'any', routePriority: undefined, isLast: false, @@ -33,14 +39,29 @@ function createFakeVersion( const siteDir = path.join(__dirname, '__fixtures__'); -const versionCurrent = createFakeVersion( - CURRENT_VERSION_NAME, - path.join(siteDir, 'docs'), -); -const version100 = createFakeVersion( - CURRENT_VERSION_NAME, - path.join(siteDir, VERSIONED_DOCS_DIR, 'version-1.0.0'), -); +const versionCurrent = createFakeVersion({ + versionName: CURRENT_VERSION_NAME, + docsDirPath: path.join(siteDir, 'docs'), + docsDirPathLocalized: path.join( + siteDir, + 'i18n', + 'fr', + 'docusaurus-plugin-content-docs', + CURRENT_VERSION_NAME, + ), +}); + +const version100 = createFakeVersion({ + versionName: '1.0.0', + docsDirPath: path.join(siteDir, VERSIONED_DOCS_DIR, 'version-1.0.0'), + docsDirPathLocalized: path.join( + siteDir, + 'i18n', + 'fr', + 'docusaurus-plugin-content-docs', + 'version-1.0.0', + ), +}); const sourceToPermalink: SourceToPermalink = { '@site/docs/doc1.md': '/docs/doc1', @@ -50,6 +71,10 @@ const sourceToPermalink: SourceToPermalink = { '@site/versioned_docs/version-1.0.0/doc2.md': '/docs/1.0.0/doc2', '@site/versioned_docs/version-1.0.0/subdir/doc1.md': '/docs/1.0.0/subdir/doc1', + + '@site/i18n/fr/docusaurus-plugin-content-docs/current/doc-localized.md': + '/fr/doc-localized', + '@site/docs/doc-localized': '/doc-localized', }; function createMarkdownOptions( @@ -85,9 +110,11 @@ test('transform to correct links', () => { expect(transformedContent).toContain('](/docs/doc1'); expect(transformedContent).toContain('](/docs/doc2'); expect(transformedContent).toContain('](/docs/subdir/doc3'); + expect(transformedContent).toContain('](/fr/doc-localized'); expect(transformedContent).not.toContain('](doc1.md)'); expect(transformedContent).not.toContain('](./doc2.md)'); expect(transformedContent).not.toContain('](subdir/doc3.md)'); + expect(transformedContent).not.toContain('](/doc-localized'); expect(content).not.toEqual(transformedContent); }); diff --git a/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts b/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts index 5d48335ba86e..87a81ab7053c 100644 --- a/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts +++ b/packages/docusaurus-plugin-content-docs/src/markdown/linkify.ts @@ -12,10 +12,13 @@ import { VersionMetadata, BrokenMarkdownLink, } from '../types'; +import {getDocsDirPaths} from '../versions'; function getVersion(filePath: string, options: DocsMarkdownOption) { const versionFound = options.versionsMetadata.find((version) => - filePath.startsWith(version.docsDirPath), + getDocsDirPaths(version).some((docsDirPath) => + filePath.startsWith(docsDirPath), + ), ); if (!versionFound) { throw new Error( @@ -32,7 +35,7 @@ function replaceMarkdownLinks( options: DocsMarkdownOption, ) { const {siteDir, sourceToPermalink, onBrokenMarkdownLink} = options; - const {docsDirPath} = version; + const {docsDirPath, docsDirPathLocalized} = version; // Replace internal markdown linking (except in fenced blocks). let fencedBlock = false; @@ -53,12 +56,15 @@ function replaceMarkdownLinks( while (mdMatch !== null) { // Replace it to correct html link. const mdLink = mdMatch[1]; - const targetSource = `${docsDirPath}/${mdLink}`; + const aliasedSource = (source: string) => `@site/${path.relative(siteDir, source)}`; + const permalink = sourceToPermalink[aliasedSource(resolve(filePath, mdLink))] || - sourceToPermalink[aliasedSource(targetSource)]; + sourceToPermalink[aliasedSource(`${docsDirPathLocalized}/${mdLink}`)] || + sourceToPermalink[aliasedSource(`${docsDirPath}/${mdLink}`)]; + if (permalink) { modifiedLine = modifiedLine.replace(mdLink, permalink); } else { diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars.ts b/packages/docusaurus-plugin-content-docs/src/sidebars.ts index 06e46d1c9967..fba543f0558d 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars.ts @@ -14,6 +14,8 @@ import { SidebarItemLink, SidebarItemDoc, Sidebar, + SidebarItemCategory, + SidebarItemType, } from './types'; import {mapValues, flatten, difference} from 'lodash'; import {getElementsAround} from '@docusaurus/utils'; @@ -213,23 +215,49 @@ export function loadSidebars(sidebarFilePath: string): Sidebars { return normalizeSidebars(sidebarJson); } -// traverse the sidebar tree in depth to find all doc items, in correct order +function collectSidebarItemsOfType< + Type extends SidebarItemType, + Item extends SidebarItem & {type: SidebarItemType} +>(type: Type, sidebar: Sidebar): Item[] { + function collectRecursive(item: SidebarItem): Item[] { + const currentItemsCollected: Item[] = + item.type === type ? [item as Item] : []; + + const childItemsCollected: Item[] = + item.type === 'category' ? flatten(item.items.map(collectRecursive)) : []; + + return [...currentItemsCollected, ...childItemsCollected]; + } + + return flatten(sidebar.map(collectRecursive)); +} + export function collectSidebarDocItems(sidebar: Sidebar): SidebarItemDoc[] { - function collectRecursive(item: SidebarItem): SidebarItemDoc[] { - if (item.type === 'doc') { - return [item]; - } + return collectSidebarItemsOfType('doc', sidebar); +} +export function collectSidebarCategories( + sidebar: Sidebar, +): SidebarItemCategory[] { + return collectSidebarItemsOfType('category', sidebar); +} +export function collectSidebarLinks(sidebar: Sidebar): SidebarItemLink[] { + return collectSidebarItemsOfType('link', sidebar); +} + +export function transformSidebarItems( + sidebar: Sidebar, + updateFn: (item: SidebarItem) => SidebarItem, +): Sidebar { + function transformRecursive(item: SidebarItem): SidebarItem { if (item.type === 'category') { - return flatten(item.items.map(collectRecursive)); - } - // Refs and links should not be shown in navigation. - if (item.type === 'ref' || item.type === 'link') { - return []; + return updateFn({ + ...item, + items: item.items.map(transformRecursive), + }); } - throw new Error(`unknown sidebar item type = ${item.type}`); + return updateFn(item); } - - return flatten(sidebar.map(collectRecursive)); + return sidebar.map(transformRecursive); } export function collectSidebarsDocIds( diff --git a/packages/docusaurus-plugin-content-docs/src/translations.ts b/packages/docusaurus-plugin-content-docs/src/translations.ts new file mode 100644 index 000000000000..f1e9b29eb371 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/translations.ts @@ -0,0 +1,259 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + LoadedVersion, + Sidebar, + LoadedContent, + Sidebars, + SidebarItem, +} from './types'; + +import {chain, mapValues, flatten, keyBy} from 'lodash'; +import { + collectSidebarCategories, + transformSidebarItems, + collectSidebarLinks, +} from './sidebars'; +import { + TranslationFileContent, + TranslationFile, + TranslationFiles, +} from '@docusaurus/types'; +import {mergeTranslations} from '@docusaurus/utils'; +import {CURRENT_VERSION_NAME} from './constants'; + +function getVersionFileName(versionName: string): string { + if (versionName === CURRENT_VERSION_NAME) { + return versionName; + } else { + // I don't like this "version-" prefix, + // but it's for consistency with site/versioned_docs + return `version-${versionName}`; + } +} + +// TODO legacy, the sidebar name is like "version-2.0.0-alpha.66/docs" +// input: "version-2.0.0-alpha.66/docs" +// output: "docs" +function getNormalizedSidebarName({ + versionName, + sidebarName, +}: { + versionName: string; + sidebarName: string; +}): string { + if (versionName === CURRENT_VERSION_NAME || !sidebarName.includes('/')) { + return sidebarName; + } + const [, ...rest] = sidebarName.split('/'); + return rest.join('/'); +} + +/* +// Do we need to translate doc metadatas? +// It seems translating frontmatter labels is good enough +function getDocTranslations(doc: DocMetadata): TranslationFileContent { + return { + [`${doc.unversionedId}.title`]: { + message: doc.title, + description: `The title for doc with id=${doc.unversionedId}`, + }, + ...(doc.sidebar_label + ? { + [`${doc.unversionedId}.sidebar_label`]: { + message: doc.sidebar_label, + description: `The sidebar label for doc with id=${doc.unversionedId}`, + }, + } + : undefined), + }; +} +function translateDoc( + doc: DocMetadata, + docsTranslations: TranslationFileContent, +): DocMetadata { + return { + ...doc, + title: docsTranslations[`${doc.unversionedId}.title`]?.message ?? doc.title, + sidebar_label: + docsTranslations[`${doc.unversionedId}.sidebar_label`]?.message ?? + doc.sidebar_label, + }; +} + +function getDocsTranslations(version: LoadedVersion): TranslationFileContent { + return mergeTranslations(version.docs.map(getDocTranslations)); +} +function translateDocs( + docs: DocMetadata[], + docsTranslations: TranslationFileContent, +): DocMetadata[] { + return docs.map((doc) => translateDoc(doc, docsTranslations)); +} + */ + +function getSidebarTranslationFileContent( + sidebar: Sidebar, + sidebarName: string, +): TranslationFileContent { + const categories = collectSidebarCategories(sidebar); + const categoryContent: TranslationFileContent = chain(categories) + .keyBy((category) => `sidebar.${sidebarName}.category.${category.label}`) + .mapValues((category) => ({ + message: category.label, + description: `The label for category ${category.label} in sidebar ${sidebarName}`, + })) + .value(); + + const links = collectSidebarLinks(sidebar); + const linksContent: TranslationFileContent = chain(links) + .keyBy((link) => `sidebar.${sidebarName}.link.${link.label}`) + .mapValues((link) => ({ + message: link.label, + description: `The label for link ${link.label} in sidebar ${sidebarName}, linking to ${link.href}`, + })) + .value(); + + return mergeTranslations([categoryContent, linksContent]); +} + +function translateSidebar({ + sidebar, + sidebarName, + sidebarsTranslations, +}: { + sidebar: Sidebar; + sidebarName: string; + sidebarsTranslations: TranslationFileContent; +}): Sidebar { + return transformSidebarItems( + sidebar, + (item: SidebarItem): SidebarItem => { + if (item.type === 'category') { + return { + ...item, + label: + sidebarsTranslations[ + `sidebar.${sidebarName}.category.${item.label}` + ]?.message ?? item.label, + }; + } + if (item.type === 'link') { + return { + ...item, + label: + sidebarsTranslations[`sidebar.${sidebarName}.link.${item.label}`] + ?.message ?? item.label, + }; + } + return item; + }, + ); +} + +function getSidebarsTranslations( + version: LoadedVersion, +): TranslationFileContent { + return mergeTranslations( + Object.entries(version.sidebars).map(([sidebarName, sidebar]) => { + const normalizedSidebarName = getNormalizedSidebarName({ + sidebarName, + versionName: version.versionName, + }); + return getSidebarTranslationFileContent(sidebar, normalizedSidebarName); + }), + ); +} +function translateSidebars( + version: LoadedVersion, + sidebarsTranslations: TranslationFileContent, +): Sidebars { + return mapValues(version.sidebars, (sidebar, sidebarName) => { + return translateSidebar({ + sidebar, + sidebarName: getNormalizedSidebarName({ + sidebarName, + versionName: version.versionName, + }), + sidebarsTranslations, + }); + }); +} + +function getVersionTranslationFiles(version: LoadedVersion): TranslationFiles { + const versionTranslations: TranslationFileContent = { + 'version.label': { + message: version.versionLabel, + description: `The label for version ${version.versionName}`, + }, + }; + + const sidebarsTranslations: TranslationFileContent = getSidebarsTranslations( + version, + ); + + // const docsTranslations: TranslationFileContent = getDocsTranslations(version); + + return [ + { + path: getVersionFileName(version.versionName), + content: mergeTranslations([ + versionTranslations, + sidebarsTranslations, + // docsTranslations, + ]), + }, + ]; +} +function translateVersion( + version: LoadedVersion, + translationFiles: Record, +): LoadedVersion { + const versionTranslations = + translationFiles[getVersionFileName(version.versionName)].content; + return { + ...version, + versionLabel: versionTranslations['version.label']?.message, + sidebars: translateSidebars(version, versionTranslations), + // docs: translateDocs(version.docs, versionTranslations), + }; +} + +function getVersionsTranslationFiles( + versions: LoadedVersion[], +): TranslationFiles { + return flatten(versions.map(getVersionTranslationFiles)); +} +function translateVersions( + versions: LoadedVersion[], + translationFiles: Record, +): LoadedVersion[] { + return versions.map((version) => translateVersion(version, translationFiles)); +} + +export function getLoadedContentTranslationFiles( + loadedContent: LoadedContent, +): TranslationFiles { + return getVersionsTranslationFiles(loadedContent.loadedVersions); +} +export function translateLoadedContent( + loadedContent: LoadedContent, + translationFiles: TranslationFile[], +): LoadedContent { + const translationFilesMap: Record = keyBy( + translationFiles, + (f) => f.path, + ); + + return { + loadedVersions: translateVersions( + loadedContent.loadedVersions, + translationFilesMap, + ), + }; +} diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index 79f0c6c3c4a5..0022ab30294d 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -9,6 +9,7 @@ /// export type DocFile = { + filePath: string; source: string; content: string; lastUpdate: LastUpdateData; @@ -21,7 +22,8 @@ export type VersionMetadata = { versionLabel: string; // Version 1.0.0 versionPath: string; // /baseUrl/docs/1.0.0 isLast: boolean; - docsDirPath: string; // versioned_docs/1.0.0 + docsDirPath: string; // "versioned_docs/version-1.0.0" + docsDirPathLocalized: string; // "i18n/fr/version-1.0.0/default" sidebarFilePath: string; // versioned_sidebars/1.0.0.json routePriority: number | undefined; // -1 for the latest docs }; @@ -91,6 +93,7 @@ export type SidebarItem = | SidebarItemCategory; export type Sidebar = SidebarItem[]; +export type SidebarItemType = SidebarItem['type']; export type Sidebars = Record; diff --git a/packages/docusaurus-plugin-content-docs/src/versions.ts b/packages/docusaurus-plugin-content-docs/src/versions.ts index e546248f84f3..90034f435f2d 100644 --- a/packages/docusaurus-plugin-content-docs/src/versions.ts +++ b/packages/docusaurus-plugin-content-docs/src/versions.ts @@ -22,7 +22,7 @@ import { import {DEFAULT_PLUGIN_ID} from '@docusaurus/core/lib/constants'; import {LoadContext} from '@docusaurus/types'; -import {normalizeUrl} from '@docusaurus/utils'; +import {getPluginI18nPath, normalizeUrl} from '@docusaurus/utils'; import {difference} from 'lodash'; import chalk from 'chalk'; @@ -137,9 +137,12 @@ function getVersionMetadataPaths({ options, }: { versionName: string; - context: Pick; + context: Pick; options: Pick; -}): Pick { +}): Pick< + VersionMetadata, + 'docsDirPath' | 'docsDirPathLocalized' | 'sidebarFilePath' +> { const isCurrentVersion = versionName === CURRENT_VERSION_NAME; const docsDirPath = isCurrentVersion @@ -149,6 +152,18 @@ function getVersionMetadataPaths({ `version-${versionName}`, ); + const docsDirPathLocalized = getPluginI18nPath({ + siteDir: context.siteDir, + locale: context.i18n.currentLocale, + pluginName: 'docusaurus-plugin-content-docs', + pluginId: options.id, + subPaths: [ + versionName === CURRENT_VERSION_NAME + ? CURRENT_VERSION_NAME + : `version-${versionName}`, + ], + }); + const sidebarFilePath = isCurrentVersion ? path.resolve(context.siteDir, options.sidebarPath) : path.join( @@ -156,7 +171,7 @@ function getVersionMetadataPaths({ `version-${versionName}-sidebars.json`, ); - return {docsDirPath, sidebarFilePath}; + return {docsDirPath, docsDirPathLocalized, sidebarFilePath}; } function createVersionMetadata({ @@ -167,13 +182,17 @@ function createVersionMetadata({ }: { versionName: string; isLast: boolean; - context: Pick; + context: Pick; options: Pick< PluginOptions, 'id' | 'path' | 'sidebarPath' | 'routeBasePath' | 'versions' >; }): VersionMetadata { - const {sidebarFilePath, docsDirPath} = getVersionMetadataPaths({ + const { + sidebarFilePath, + docsDirPath, + docsDirPathLocalized, + } = getVersionMetadataPaths({ versionName, context, options, @@ -210,6 +229,7 @@ function createVersionMetadata({ routePriority, sidebarFilePath, docsDirPath, + docsDirPathLocalized, }; } @@ -322,7 +342,7 @@ export function readVersionsMetadata({ context, options, }: { - context: Pick; + context: Pick; options: Pick< PluginOptions, | 'id' @@ -356,3 +376,15 @@ export function readVersionsMetadata({ versionsMetadata.forEach(checkVersionMetadataPaths); return versionsMetadata; } + +// order matter! +// Read in priority the localized path, then the unlocalized one +// We want the localized doc to "override" the unlocalized one +export function getDocsDirPaths( + versionMetadata: Pick< + VersionMetadata, + 'docsDirPath' | 'docsDirPathLocalized' + >, +): [string, string] { + return [versionMetadata.docsDirPathLocalized, versionMetadata.docsDirPath]; +} diff --git a/packages/docusaurus-plugin-content-pages/package.json b/packages/docusaurus-plugin-content-pages/package.json index be4167f4db23..0c264c291855 100644 --- a/packages/docusaurus-plugin-content-pages/package.json +++ b/packages/docusaurus-plugin-content-pages/package.json @@ -26,6 +26,7 @@ "globby": "^10.0.1", "joi": "^17.2.1", "loader-utils": "^1.2.3", + "lodash": "^4.17.19", "minimatch": "^3.0.4", "remark-admonitions": "^1.2.1", "slash": "^3.0.0", diff --git a/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedJs.js b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedJs.js new file mode 100644 index 000000000000..f8fff55f1453 --- /dev/null +++ b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedJs.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +export default class TranslatedJs extends React.Component { + render() { + return
TranslatedJsPage (fr)
; + } +} diff --git a/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedMd.md b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedMd.md new file mode 100644 index 000000000000..51a19b7d482d --- /dev/null +++ b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/i18n/fr/docusaurus-plugin-content-pages/hello/translatedMd.md @@ -0,0 +1 @@ +translated markdown page (fr) diff --git a/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedJs.js b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedJs.js new file mode 100644 index 000000000000..74c430498c1e --- /dev/null +++ b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedJs.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +export default class TranslatedJs extends React.Component { + render() { + return
TranslatedJsPage
; + } +} diff --git a/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedMd.md b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedMd.md new file mode 100644 index 000000000000..d39c88c732e2 --- /dev/null +++ b/packages/docusaurus-plugin-content-pages/src/__tests__/__fixtures__/website/src/pages/hello/translatedMd.md @@ -0,0 +1 @@ +translated markdown page diff --git a/packages/docusaurus-plugin-content-pages/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-pages/src/__tests__/index.test.ts index 60ff61bc0c04..daddf4062b33 100644 --- a/packages/docusaurus-plugin-content-pages/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-pages/src/__tests__/index.test.ts @@ -14,7 +14,7 @@ import normalizePluginOptions from './pluginOptionSchema.test'; describe('docusaurus-plugin-content-pages', () => { test('simple pages', async () => { const siteDir = path.join(__dirname, '__fixtures__', 'website'); - const context = loadContext(siteDir); + const context = await loadContext(siteDir); const pluginPath = 'src/pages'; const plugin = pluginContentPages( context, @@ -22,7 +22,7 @@ describe('docusaurus-plugin-content-pages', () => { path: pluginPath, }), ); - const pagesMetadatas = await plugin.loadContent(); + const pagesMetadatas = (await plugin.loadContent?.())!; expect(pagesMetadatas).toEqual([ { @@ -45,6 +45,80 @@ describe('docusaurus-plugin-content-pages', () => { permalink: '/hello/mdxPage', source: path.join('@site', pluginPath, 'hello', 'mdxPage.mdx'), }, + { + type: 'jsx', + permalink: '/hello/translatedJs', + source: path.join('@site', pluginPath, 'hello', 'translatedJs.js'), + }, + { + type: 'mdx', + permalink: '/hello/translatedMd', + source: path.join('@site', pluginPath, 'hello', 'translatedMd.md'), + }, + { + type: 'jsx', + permalink: '/hello/world', + source: path.join('@site', pluginPath, 'hello', 'world.js'), + }, + ]); + }); + + test('simple pages with french translations', async () => { + const siteDir = path.join(__dirname, '__fixtures__', 'website'); + const context = await loadContext(siteDir); + const pluginPath = 'src/pages'; + const plugin = pluginContentPages( + { + ...context, + i18n: { + ...context.i18n, + currentLocale: 'fr', + }, + }, + normalizePluginOptions({ + path: pluginPath, + }), + ); + const pagesMetadatas = (await plugin.loadContent?.())!; + + const frTranslationsPath = path.join( + '@site', + 'i18n', + 'fr', + 'docusaurus-plugin-content-pages', + ); + + expect(pagesMetadatas).toEqual([ + { + type: 'jsx', + permalink: '/', + source: path.join('@site', pluginPath, 'index.js'), + }, + { + type: 'jsx', + permalink: '/typescript', + source: path.join('@site', pluginPath, 'typescript.tsx'), + }, + { + type: 'mdx', + permalink: '/hello/', + source: path.join('@site', pluginPath, 'hello', 'index.md'), + }, + { + type: 'mdx', + permalink: '/hello/mdxPage', + source: path.join('@site', pluginPath, 'hello', 'mdxPage.mdx'), + }, + { + type: 'jsx', + permalink: '/hello/translatedJs', + source: path.join(frTranslationsPath, 'hello', 'translatedJs.js'), + }, + { + type: 'mdx', + permalink: '/hello/translatedMd', + source: path.join(frTranslationsPath, 'hello', 'translatedMd.md'), + }, { type: 'jsx', permalink: '/hello/world', diff --git a/packages/docusaurus-plugin-content-pages/src/index.ts b/packages/docusaurus-plugin-content-pages/src/index.ts index 53179884377e..3e7e3db32f87 100644 --- a/packages/docusaurus-plugin-content-pages/src/index.ts +++ b/packages/docusaurus-plugin-content-pages/src/index.ts @@ -15,6 +15,8 @@ import { fileToPath, aliasedSitePath, docuHash, + getPluginI18nPath, + getFolderContainingFile, } from '@docusaurus/utils'; import { LoadContext, @@ -32,7 +34,17 @@ import { STATIC_DIR_NAME, } from '@docusaurus/core/lib/constants'; -import {PluginOptions, LoadedContent, Metadata} from './types'; +import { + PluginOptions, + LoadedContent, + Metadata, + PagesContentPaths, +} from './types'; +import {flatten} from 'lodash'; + +export function getContentPathList(contentPaths: PagesContentPaths) { + return [contentPaths.contentPathLocalized, contentPaths.contentPath]; +} const isMarkdownSource = (source: string) => source.endsWith('.md') || source.endsWith('.mdx'); @@ -46,9 +58,22 @@ export default function pluginContentPages( [admonitions, options.admonitions || {}], ]); } - const {siteConfig, siteDir, generatedFilesDir} = context; + const { + siteConfig, + siteDir, + generatedFilesDir, + i18n: {currentLocale}, + } = context; - const contentPath = path.resolve(siteDir, options.path); + const contentPaths: PagesContentPaths = { + contentPath: path.resolve(siteDir, options.path), + contentPathLocalized: getPluginI18nPath({ + siteDir, + locale: currentLocale, + pluginName: 'docusaurus-plugin-content-pages', + pluginId: options.id, + }), + }; const pluginDataDirRoot = path.join( generatedFilesDir, @@ -66,8 +91,11 @@ export default function pluginContentPages( getPathsToWatch() { const {include = []} = options; - const globPattern = include.map((pattern) => `${contentPath}/${pattern}`); - return [...globPattern]; + return flatten( + getContentPathList(contentPaths).map((contentPath) => { + return include.map((pattern) => `${contentPath}/${pattern}`); + }), + ); }, getClientModules() { @@ -82,20 +110,25 @@ export default function pluginContentPages( async loadContent() { const {include} = options; - const pagesDir = contentPath; - if (!fs.existsSync(pagesDir)) { + if (!fs.existsSync(contentPaths.contentPath)) { return null; } const {baseUrl} = siteConfig; const pagesFiles = await globby(include, { - cwd: pagesDir, + cwd: contentPaths.contentPath, ignore: options.exclude, }); - function toMetadata(relativeSource: string): Metadata { - const source = path.join(pagesDir, relativeSource); + async function toMetadata(relativeSource: string): Promise { + // Lookup in localized folder in priority + const contentPath = await getFolderContainingFile( + getContentPathList(contentPaths), + relativeSource, + ); + + const source = path.join(contentPath, relativeSource); const aliasedSourcePath = aliasedSitePath(source, siteDir); const pathName = encodePath(fileToPath(relativeSource)); const permalink = pathName.replace(/^\//, baseUrl || ''); @@ -114,7 +147,7 @@ export default function pluginContentPages( } } - return pagesFiles.map(toMetadata); + return Promise.all(pagesFiles.map(toMetadata)); }, async contentLoaded({content, actions}) { @@ -177,7 +210,7 @@ export default function pluginContentPages( rules: [ { test: /(\.mdx?)$/, - include: [contentPath], + include: getContentPathList(contentPaths), use: [ getCacheLoader(isServer), getBabelLoader(isServer), diff --git a/packages/docusaurus-plugin-content-pages/src/types.ts b/packages/docusaurus-plugin-content-pages/src/types.ts index b56e84f7b468..f53e1f115d23 100644 --- a/packages/docusaurus-plugin-content-pages/src/types.ts +++ b/packages/docusaurus-plugin-content-pages/src/types.ts @@ -34,3 +34,8 @@ export type MDXPageMetadata = { export type Metadata = JSXPageMetadata | MDXPageMetadata; export type LoadedContent = Metadata[]; + +export type PagesContentPaths = { + contentPath: string; + contentPathLocalized: string; +}; diff --git a/packages/docusaurus-theme-classic/babel.config.js b/packages/docusaurus-theme-classic/babel.config.js index a6b570f38777..9e75cdf74863 100644 --- a/packages/docusaurus-theme-classic/babel.config.js +++ b/packages/docusaurus-theme-classic/babel.config.js @@ -6,5 +6,30 @@ */ module.exports = { - presets: [['@babel/preset-typescript', {isTSX: true, allExtensions: true}]], + env: { + // USED FOR NODE/RUNTIME + // maybe we should differenciate both cases because + // we mostly need to transpile some features so that node does not crash... + lib: { + presets: [ + ['@babel/preset-typescript', {isTSX: true, allExtensions: true}], + ], + // Useful to transpile for older node versions + plugins: [ + '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-proposal-nullish-coalescing-operator', + '@babel/plugin-proposal-optional-chaining', + ], + }, + + // USED FOR JS SWIZZLE + // /lib-next folder is used as source to swizzle JS source code + // This JS code is created from TS source code + // This source code should look clean/human readable to be usable + 'lib-next': { + presets: [ + ['@babel/preset-typescript', {isTSX: true, allExtensions: true}], + ], + }, + }, }; diff --git a/packages/docusaurus-theme-classic/package.json b/packages/docusaurus-theme-classic/package.json index a1eeab83d916..4906ffa42fb8 100644 --- a/packages/docusaurus-theme-classic/package.json +++ b/packages/docusaurus-theme-classic/package.json @@ -2,7 +2,7 @@ "name": "@docusaurus/theme-classic", "version": "2.0.0-alpha.69", "description": "Classic theme for Docusaurus", - "main": "src/index.js", + "main": "lib/index.js", "types": "src/types.d.ts", "publishConfig": { "access": "public" @@ -14,9 +14,10 @@ }, "license": "MIT", "scripts": { - "build": "tsc --noEmit && yarn babel && yarn prettier", - "watch": "yarn babel --watch", - "babel": "babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files", + "build": "tsc --noEmit && yarn babel:lib && yarn babel:lib-next && yarn prettier", + "watch": "yarn babel:lib --watch", + "babel:lib": "cross-env BABEL_ENV=lib babel src -d lib --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files", + "babel:lib-next": "cross-env BABEL_ENV=lib-next babel src -d lib-next --extensions \".tsx,.ts\" --ignore \"**/*.d.ts\" --copy-files", "prettier": "prettier --config ../../.prettierrc --write \"**/*.{js,ts}\"" }, "dependencies": { @@ -27,6 +28,7 @@ "@docusaurus/theme-common": "2.0.0-alpha.69", "@docusaurus/types": "2.0.0-alpha.69", "@docusaurus/utils-validation": "2.0.0-alpha.69", + "@docusaurus/utils": "2.0.0-alpha.69", "@mdx-js/mdx": "^1.6.21", "@mdx-js/react": "^1.6.21", "@types/react-toggle": "^4.0.2", diff --git a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap new file mode 100644 index 000000000000..8e2dc4a88bc0 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getTranslationFiles should return translation files matching snapshot 1`] = ` +Array [ + Object { + "content": Object { + "item.label.Dropdown": Object { + "description": "Navbar item with label Dropdown", + "message": "Dropdown", + }, + "item.label.Dropdown item 1": Object { + "description": "Navbar item with label Dropdown item 1", + "message": "Dropdown item 1", + }, + "title": Object { + "description": "The title in the navbar", + "message": "navbar title", + }, + }, + "path": "navbar", + }, + Object { + "content": Object { + "copyright": Object { + "description": "The footer copyright", + "message": "Copyright FB", + }, + "link.item.label.Link 1": Object { + "description": "The label of footer link with label=Link 1 linking to https://facebook.com", + "message": "Link 1", + }, + "link.item.label.Link 2": Object { + "description": "The label of footer link with label=Link 2 linking to https://facebook.com", + "message": "Link 2", + }, + "link.item.label.Link 3": Object { + "description": "The label of footer link with label=Link 3 linking to https://facebook.com", + "message": "Link 3", + }, + "link.title.Footer link column 1": Object { + "description": "The title of the footer links column with title=Footer link column 1 in the footer", + "message": "Footer link column 1", + }, + "link.title.Footer link column 2": Object { + "description": "The title of the footer links column with title=Footer link column 2 in the footer", + "message": "Footer link column 2", + }, + }, + "path": "footer", + }, +] +`; + +exports[`translateThemeConfig should return translated themeConfig matching snapshot 1`] = ` +Object { + "announcementBar": Object {}, + "colorMode": Object {}, + "docs": Object { + "versionPersistence": "none", + }, + "footer": Object { + "copyright": "Copyright FB (translated)", + "links": Array [ + Object { + "items": Array [ + Object { + "label": "Link 1 (translated)", + "to": "https://facebook.com", + }, + Object { + "label": "Link 2 (translated)", + "to": "https://facebook.com", + }, + ], + "title": "Footer link column 1 (translated)", + }, + Object { + "items": Array [ + Object { + "label": "Link 3 (translated)", + "to": "https://facebook.com", + }, + ], + "title": "Footer link column 2 (translated)", + }, + ], + "style": "light", + }, + "hideableSidebar": true, + "navbar": Object { + "hideOnScroll": false, + "items": Array [ + Object { + "items": Array [ + Object { + "items": Array [], + "label": "Dropdown item 1", + }, + ], + "label": "Dropdown (translated)", + }, + ], + "style": "dark", + "title": "navbar title (translated)", + }, + "prism": Object {}, +} +`; diff --git a/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts b/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts new file mode 100644 index 000000000000..8bde386a0421 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {getTranslationFiles, translateThemeConfig} from '../translations'; +import {ThemeConfig} from '@docusaurus/theme-common'; +import {updateTranslationFileMessages} from '@docusaurus/utils'; + +const ThemeConfigSample: ThemeConfig = { + colorMode: {}, + announcementBar: {}, + prism: {}, + docs: { + versionPersistence: 'none', + }, + hideableSidebar: true, + navbar: { + title: 'navbar title', + style: 'dark', + hideOnScroll: false, + items: [ + {label: 'Dropdown', items: [{label: 'Dropdown item 1', items: []}]}, + ], + }, + footer: { + copyright: 'Copyright FB', + style: 'light', + links: [ + { + title: 'Footer link column 1', + items: [ + {label: 'Link 1', to: 'https://facebook.com'}, + {label: 'Link 2', to: 'https://facebook.com'}, + ], + }, + { + title: 'Footer link column 2', + items: [{label: 'Link 3', to: 'https://facebook.com'}], + }, + ], + }, +}; + +function getSampleTranslationFiles() { + return getTranslationFiles({ + themeConfig: ThemeConfigSample, + }); +} + +function getSampleTranslationFilesTranslated() { + const translationFiles = getSampleTranslationFiles(); + return translationFiles.map((translationFile) => + updateTranslationFileMessages( + translationFile, + (message) => `${message} (translated)`, + ), + ); +} + +describe('getTranslationFiles', () => { + test('should return translation files matching snapshot', () => { + expect(getSampleTranslationFiles()).toMatchSnapshot(); + }); +}); + +describe('translateThemeConfig', () => { + test('should not translate anything if translation files are untranslated', () => { + const translationFiles = getSampleTranslationFiles(); + expect( + translateThemeConfig({ + themeConfig: ThemeConfigSample, + translationFiles, + }), + ).toEqual(ThemeConfigSample); + }); + + test('should return translated themeConfig matching snapshot', () => { + const translationFiles = getSampleTranslationFilesTranslated(); + expect( + translateThemeConfig({ + themeConfig: ThemeConfigSample, + translationFiles, + }), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/docusaurus-theme-classic/src/index.js b/packages/docusaurus-theme-classic/src/index.ts similarity index 78% rename from packages/docusaurus-theme-classic/src/index.js rename to packages/docusaurus-theme-classic/src/index.ts index ff0d71749fff..1b2b949a1fab 100644 --- a/packages/docusaurus-theme-classic/src/index.js +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -5,9 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -const path = require('path'); -const Module = require('module'); -const {validateThemeConfig} = require('./validateThemeConfig'); +import {Plugin} from '@docusaurus/types'; +import {getTranslationFiles, translateThemeConfig} from './translations'; +import path from 'path'; +import Module from 'module'; const createRequire = Module.createRequire || Module.createRequireFromPath; const requireFromDocusaurusCore = createRequire( @@ -58,7 +59,10 @@ const noFlashColorMode = ({defaultMode, respectPrefersColorScheme}) => { })();`; }; -module.exports = function (context, options) { +export default function docusaurusThemeClassic( + context, + options, +): Plugin { const { siteConfig: {themeConfig}, } = context; @@ -69,13 +73,16 @@ module.exports = function (context, options) { name: 'docusaurus-theme-classic', getThemePath() { - return path.join(__dirname, '..', 'lib', 'theme'); + return path.join(__dirname, '..', 'lib-next', 'theme'); }, getTypeScriptThemePath() { return path.resolve(__dirname, './theme'); }, + getTranslationFiles: async () => getTranslationFiles({themeConfig}), + translateThemeConfig, + getClientModules() { const modules = [ require.resolve('infima/dist/css/default/default.css'), @@ -98,12 +105,15 @@ module.exports = function (context, options) { .map((lang) => `prism-${lang}`) .join('|'); + // See https://github.com/facebook/docusaurus/pull/3382 + const useDocsWarningFilter = (warning: string) => + warning.includes("Can't resolve '@theme-init/hooks/useDocs"); + return { stats: { warningsFilter: [ - // See https://github.com/facebook/docusaurus/pull/3382 - (warning) => - warning.includes("Can't resolve '@theme-init/hooks/useDocs"), + // The TS def does not allow function for array item :( + useDocsWarningFilter as any, ], }, plugins: [ @@ -129,7 +139,7 @@ module.exports = function (context, options) { }; }, }; -}; +} const swizzleAllowedComponents = [ 'CodeBlock', @@ -141,6 +151,8 @@ const swizzleAllowedComponents = [ 'prism-include-languages', ]; -module.exports.getSwizzleComponentList = () => swizzleAllowedComponents; +export function getSwizzleComponentList() { + return swizzleAllowedComponents; +} -module.exports.validateThemeConfig = validateThemeConfig; +export {validateThemeConfig} from './validateThemeConfig'; diff --git a/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx b/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx index b1bc6b3a5828..e0059bfcc9ae 100644 --- a/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx @@ -13,7 +13,7 @@ import {useThemeConfig} from '@docusaurus/theme-common'; import useBaseUrl from '@docusaurus/useBaseUrl'; import styles from './styles.module.css'; -function FooterLink({to, href, label, prependBaseUrlToHref, ...props}) { +function FooterLink({to, href, label, prependBaseUrlToHref, ...props}: any) { const toUrl = useBaseUrl(to); const normalizedHref = useBaseUrl(href, {forcePrependBaseUrl: true}); @@ -111,7 +111,7 @@ function Footer(): JSX.Element | null { // Developer provided the HTML, so assume it's safe. // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ - __html: copyright, + __html: copyright ?? '', }} /> diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutHead/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutHead/index.tsx index 7a25c3b05b20..54eee13017e8 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutHead/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutHead/index.tsx @@ -14,7 +14,10 @@ import SearchMetadatas from '@theme/SearchMetadatas'; import {DEFAULT_SEARCH_TAG} from '@docusaurus/theme-common'; export default function LayoutHead(props: Props): JSX.Element { - const {siteConfig} = useDocusaurusContext(); + const { + siteConfig, + i18n: {currentLocale}, + } = useDocusaurusContext(); const { favicon, title: siteTitle, @@ -36,11 +39,12 @@ export default function LayoutHead(props: Props): JSX.Element { const metaImage = image || defaultImage; const metaImageUrl = useBaseUrl(metaImage, {absolute: true}); const faviconUrl = useBaseUrl(favicon); + + const htmlLang = currentLocale.split('-')[0]; return ( <> - {/* TODO: Do not assume that it is in english language */} - + {metaTitle && {metaTitle}} {metaTitle && } {favicon && } @@ -63,7 +67,7 @@ export default function LayoutHead(props: Props): JSX.Element { diff --git a/packages/docusaurus-theme-classic/src/theme/Logo/index.tsx b/packages/docusaurus-theme-classic/src/theme/Logo/index.tsx index 33eb28e48eef..c02937542346 100644 --- a/packages/docusaurus-theme-classic/src/theme/Logo/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Logo/index.tsx @@ -18,7 +18,7 @@ import isInternalUrl from '@docusaurus/isInternalUrl'; const Logo = (props: Props): JSX.Element => { const {isClient} = useDocusaurusContext(); const { - navbar: {title, logo = {}}, + navbar: {title, logo = {src: ''}}, } = useThemeConfig(); const {imageClassName, titleClassName, ...propsRest} = props; diff --git a/packages/docusaurus-theme-classic/src/theme/SearchMetadatas/index.tsx b/packages/docusaurus-theme-classic/src/theme/SearchMetadatas/index.tsx index aa18b5d10619..6c8886801e5b 100644 --- a/packages/docusaurus-theme-classic/src/theme/SearchMetadatas/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/SearchMetadatas/index.tsx @@ -10,7 +10,7 @@ import React from 'react'; import Head from '@docusaurus/Head'; type SearchTagMetaProps = { - language?: string; + locale?: string; version?: string; tag?: string; }; @@ -19,13 +19,13 @@ type SearchTagMetaProps = { // We may want to support other search engine plugins too // Search plugins should swizzle/override this comp to add their behavior export default function SearchMetadatas({ - language, + locale, version, tag, }: SearchTagMetaProps) { return ( - {language && } + {locale && } {version && } {tag && } diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useContextualSearchFilters.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useContextualSearchFilters.ts index dccdbc24025b..5e4a2a792948 100644 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useContextualSearchFilters.ts +++ b/packages/docusaurus-theme-classic/src/theme/hooks/useContextualSearchFilters.ts @@ -10,15 +10,17 @@ import { DEFAULT_SEARCH_TAG, docVersionSearchTag, } from '@docusaurus/theme-common'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; type ContextualSearchFilters = { - language: string; + locale: string; tags: string[]; }; // We may want to support multiple search engines, don't couple that to Algolia/DocSearch // Maybe users will want to use its own search engine solution export default function useContextualSearchFilters(): ContextualSearchFilters { + const {siteConfig} = useDocusaurusContext(); const allDocsData = useAllDocsData(); const activePluginAndVersion = useActivePluginAndVersion(); const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId(); @@ -38,15 +40,13 @@ export default function useContextualSearchFilters(): ContextualSearchFilters { return docVersionSearchTag(pluginId, version.name); } - const language = 'en'; // TODO i18n - const tags = [ DEFAULT_SEARCH_TAG, ...Object.keys(allDocsData).map(getDocPluginTags), ]; return { - language, + locale: siteConfig.i18n.currentLocale, tags, }; } diff --git a/packages/docusaurus-theme-classic/src/translations.ts b/packages/docusaurus-theme-classic/src/translations.ts new file mode 100644 index 000000000000..1c30062b241c --- /dev/null +++ b/packages/docusaurus-theme-classic/src/translations.ts @@ -0,0 +1,161 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {TranslationFile, TranslationFileContent} from '@docusaurus/types'; +import { + ThemeConfig, + Navbar, + NavbarItem, + Footer, +} from '@docusaurus/theme-common'; + +import {keyBy, chain, flatten} from 'lodash'; +import {mergeTranslations} from '@docusaurus/utils'; + +function getNavbarTranslationFile(navbar: Navbar): TranslationFileContent { + // TODO handle properly all the navbar item types here! + function flattenNavbarItems(items: NavbarItem[]): NavbarItem[] { + const subItems = flatten( + items.map((item) => { + const allSubItems = flatten([item.items ?? []]); + return flattenNavbarItems(allSubItems); + }), + ); + return [...items, ...subItems]; + } + + const allNavbarItems = flattenNavbarItems(navbar.items); + + const navbarItemsTranslations: TranslationFileContent = chain( + allNavbarItems.filter((navbarItem) => !!navbarItem.label), + ) + .keyBy((navbarItem) => `item.label.${navbarItem.label}`) + .mapValues((navbarItem) => ({ + message: navbarItem.label!, + description: `Navbar item with label ${navbarItem.label}`, + })) + .value(); + + const titleTranslations: TranslationFileContent = navbar.title + ? {title: {message: navbar.title, description: 'The title in the navbar'}} + : {}; + + return mergeTranslations([titleTranslations, navbarItemsTranslations]); +} +function translateNavbar( + navbar: Navbar, + navbarTranslations: TranslationFileContent, +): Navbar { + return { + ...navbar, + title: navbarTranslations.title?.message ?? navbar.title, + // TODO handle properly all the navbar item types here! + items: navbar.items.map((item) => ({ + ...item, + label: + navbarTranslations[`item.label.${item.label}`]?.message ?? item.label, + })), + }; +} + +function getFooterTranslationFile(footer: Footer): TranslationFileContent { + // TODO POC code + const footerLinkTitles: TranslationFileContent = chain( + footer.links.filter((link) => !!link.title), + ) + .keyBy((link) => `link.title.${link.title}`) + .mapValues((link) => ({ + message: link.title!, + description: `The title of the footer links column with title=${link.title} in the footer`, + })) + .value(); + + const footerLinkLabels: TranslationFileContent = chain( + flatten(footer.links.map((link) => link.items)).filter( + (link) => !!link.label, + ), + ) + .keyBy((linkItem) => `link.item.label.${linkItem.label}`) + .mapValues((linkItem) => ({ + message: linkItem.label!, + description: `The label of footer link with label=${ + linkItem.label + } linking to ${linkItem.to ?? linkItem.href}`, + })) + .value(); + + const copyright: TranslationFileContent = footer.copyright + ? { + copyright: { + message: footer.copyright, + description: 'The footer copyright', + }, + } + : {}; + + return mergeTranslations([footerLinkTitles, footerLinkLabels, copyright]); +} +function translateFooter( + footer: Footer, + footerTranslations: TranslationFileContent, +): Footer { + const links = footer.links.map((link) => ({ + ...link, + title: + footerTranslations[`link.title.${link.title}`]?.message ?? link.title, + items: link.items.map((linkItem) => ({ + ...linkItem, + label: + footerTranslations[`link.item.label.${linkItem.label}`]?.message ?? + linkItem.label, + })), + })); + + const copyright = footerTranslations.copyright?.message ?? footer.copyright; + + return { + ...footer, + links, + copyright, + }; +} + +export function getTranslationFiles({ + themeConfig, +}: { + themeConfig: ThemeConfig; +}): TranslationFile[] { + return [ + {path: 'navbar', content: getNavbarTranslationFile(themeConfig.navbar)}, + {path: 'footer', content: getFooterTranslationFile(themeConfig.footer)}, + ]; +} + +export function translateThemeConfig({ + themeConfig, + translationFiles, +}: { + themeConfig: ThemeConfig; + translationFiles: TranslationFile[]; +}): ThemeConfig { + const translationFilesMap: Record = keyBy( + translationFiles, + (f) => f.path, + ); + + return { + ...themeConfig, + navbar: translateNavbar( + themeConfig.navbar, + translationFilesMap.navbar.content, + ), + footer: translateFooter( + themeConfig.footer, + translationFilesMap.footer.content, + ), + }; +} diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index d76e5a3f4dc1..79f7631c4ced 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -347,7 +347,7 @@ declare module '@theme/NavbarItem' { import type {Props as DocsVersionNavbarItemProps} from '@theme/NavbarItem/DocsVersionNavbarItem'; export type Props = - | ({readonly type: 'default'} & DefaultNavbarItemProps) + | ({readonly type?: 'default' | undefined} & DefaultNavbarItemProps) | ({ readonly type: 'docsVersionDropdown'; } & DocsVersionDropdownNavbarItemProps) diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 84c003e241c0..917375b4104d 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -5,8 +5,19 @@ * LICENSE file in the root directory of this source tree. */ -export {useThemeConfig, ThemeConfig} from './utils/useThemeConfig'; +export { + useThemeConfig, + ThemeConfig, + Navbar, + NavbarItem, + NavbarLogo, + Footer, + FooterLinks, + FooterLinkItem, +} from './utils/useThemeConfig'; + export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; + export {isDocsPluginEnabled} from './utils/docsUtils'; export {isSamePath} from './utils/pathUtils'; diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index b03ab20401d7..394f4b32f65d 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -8,6 +8,50 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; export type DocsVersionPersistence = 'localStorage' | 'none'; +// TODO improve +export type NavbarItem = { + items?: NavbarItem[]; + label?: string; +}; + +export type NavbarLogo = { + src: string; + srcDark?: string; + href?: string; + target?: string; + alt?: string; +}; + +// TODO improve +export type Navbar = { + style: 'dark' | 'primary'; + hideOnScroll: boolean; + title?: string; + items: NavbarItem[]; + logo?: NavbarLogo; +}; + +export type FooterLinkItem = { + label?: string; + to?: string; + href?: string; + html?: string; +}; +export type FooterLinks = { + title?: string; + items: FooterLinkItem[]; +}; +export type Footer = { + style: 'light' | 'dark'; + logo?: { + alt?: string; + src?: string; + href?: string; + }; + copyright?: string; + links: FooterLinks[]; +}; + export type ThemeConfig = { docs: { versionPersistence: DocsVersionPersistence; @@ -18,11 +62,11 @@ export type ThemeConfig = { // and use it in the Joi validation schema? // TODO temporary types - navbar: any; + navbar: Navbar; colorMode: any; announcementBar: any; prism: any; - footer: any; + footer: Footer; hideableSidebar: any; }; diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchMetadatas/index.js b/packages/docusaurus-theme-search-algolia/src/theme/SearchMetadatas/index.js index 16fe29c37e8e..f83b7ba1cdc6 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/SearchMetadatas/index.js +++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchMetadatas/index.js @@ -10,10 +10,14 @@ import React from 'react'; import Head from '@docusaurus/Head'; // Override default/agnostic SearchMetas to use Algolia-specific metadatas -export default function AlgoliaSearchMetadatas({language, version, tag}) { +export default function AlgoliaSearchMetadatas({locale, version, tag}) { + // Seems safe to consider here the locale is the language, + // as the existing docsearch:language filter is afaik a regular string-based filter + const language = locale; + return ( - {language && } + {language && } {version && } {tag && } diff --git a/packages/docusaurus-theme-search-algolia/src/theme/hooks/useAlgoliaContextualFacetFilters.js b/packages/docusaurus-theme-search-algolia/src/theme/hooks/useAlgoliaContextualFacetFilters.js index 604f4ff46fae..c09ef21963ba 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/hooks/useAlgoliaContextualFacetFilters.js +++ b/packages/docusaurus-theme-search-algolia/src/theme/hooks/useAlgoliaContextualFacetFilters.js @@ -9,9 +9,10 @@ import useContextualSearchFilters from '@theme/hooks/useContextualSearchFilters' // Translate search-engine agnostic search filters to Algolia search filters export default function useAlgoliaContextualFacetFilters() { - const {language, tags} = useContextualSearchFilters(); + const {locale, tags} = useContextualSearchFilters(); - const languageFilter = `language:${language}`; + // seems safe to convert locale->language, see AlgoliaSearchMetadatas comment + const languageFilter = `language:${locale}`; const tagsFilter = tags.map((tag) => `docusaurus_tag:${tag}`); diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 42a3d73ffde3..107541525d6b 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -14,6 +14,10 @@ import {MergeStrategy} from 'webpack-merge'; export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'error' | 'throw'; +export type ThemeConfig = { + [key: string]: unknown; +}; + export interface DocusaurusConfig { baseUrl: string; baseUrlIssueBanner: boolean; @@ -21,6 +25,7 @@ export interface DocusaurusConfig { tagline?: string; title: string; url: string; + i18n: I18nConfig; onBrokenLinks: ReportingSeverity; onBrokenMarkdownLinks: ReportingSeverity; onDuplicateRoutes: ReportingSeverity; @@ -31,9 +36,7 @@ export interface DocusaurusConfig { plugins?: PluginConfig[]; themes?: PluginConfig[]; presets?: PresetConfig[]; - themeConfig?: { - [key: string]: unknown; - }; + themeConfig: ThemeConfig; customFields?: { [key: string]: unknown; }; @@ -78,10 +81,32 @@ export interface DocusaurusSiteMetadata { readonly pluginVersions: Record; } +// Inspired by Chrome JSON, because it's a widely supported i18n format +// https://developer.chrome.com/apps/i18n-messages +// https://support.crowdin.com/file-formats/chrome-json/ +// https://www.applanga.com/docs/formats/chrome_i18n_json +// https://docs.transifex.com/formats/chrome-json +// https://help.phrase.com/help/chrome-json-messages +export type TranslationMessage = {message: string; description?: string}; +export type TranslationFileContent = Record; +export type TranslationFile = {path: string; content: TranslationFileContent}; +export type TranslationFiles = TranslationFile[]; + +export type I18nConfig = { + defaultLocale: string; + locales: [string, ...string[]]; +}; + +export type I18n = I18nConfig & { + currentLocale: string; +}; + export interface DocusaurusContext { siteConfig: DocusaurusConfig; siteMetadata: DocusaurusSiteMetadata; globalData: Record; + i18n: I18n; + codeTranslations: Record; isClient: boolean; } @@ -104,6 +129,7 @@ export type StartCLIOptions = HostPortCLIOptions & { hotOnly: boolean; open: boolean; poll: boolean | number; + locale?: string; }; export type ServeCLIOptions = HostPortCLIOptions & { @@ -111,12 +137,16 @@ export type ServeCLIOptions = HostPortCLIOptions & { dir: string; }; -export interface BuildCLIOptions { +export type BuildOptions = { bundleAnalyzer: boolean; outDir: string; minify: boolean; skipBuild: boolean; -} +}; + +export type BuildCLIOptions = BuildOptions & { + locale?: string; +}; export interface LoadContext { siteDir: string; @@ -124,7 +154,9 @@ export interface LoadContext { siteConfig: DocusaurusConfig; outDir: string; baseUrl: string; + i18n: I18n; ssrTemplate?: string; + codeTranslations: Record; } export interface InjectedHtmlTags { @@ -187,6 +219,23 @@ export interface Plugin { postBodyTags?: HtmlTags; }; getSwizzleComponentList?(): string[]; + + // translations + getTranslationFiles?(): Promise; + translateContent?({ + content, + translationFiles, + }: { + content: T; // the content loaded by this plugin instance + translationFiles: TranslationFiles; + }): T; + translateThemeConfig?({ + themeConfig, + translationFiles, + }: { + themeConfig: ThemeConfig; + translationFiles: TranslationFiles; + }): ThemeConfig; } export type ConfigureWebpackFn = Plugin['configureWebpack']; diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index df27444e3482..79b58c68b6c1 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -23,6 +23,7 @@ "escape-string-regexp": "^2.0.0", "fs-extra": "^9.0.1", "gray-matter": "^4.0.2", + "lodash": "^4.17.20", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "resolve-pathname": "^3.0.0" diff --git a/packages/docusaurus-utils/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-utils/src/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000000..58a2706fd16e --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getFolderContainingFile throw if no folder contain such file 1`] = ` +"relativeFilePath=[index.test.ts] does not exist in any of these folders: +- /abcdef +- /gehij +- /klmn]" +`; diff --git a/packages/docusaurus-utils/src/__tests__/index.test.ts b/packages/docusaurus-utils/src/__tests__/index.test.ts index b9627f215608..7fba180bfd73 100644 --- a/packages/docusaurus-utils/src/__tests__/index.test.ts +++ b/packages/docusaurus-utils/src/__tests__/index.test.ts @@ -27,7 +27,14 @@ import { getFilePathForRoutePath, addLeadingSlash, getElementsAround, + mergeTranslations, + mapAsyncSequencial, + findAsyncSequential, + findFolderContainingFile, + getFolderContainingFile, + updateTranslationFileMessages, } from '../index'; +import {sum} from 'lodash'; describe('load utils', () => { test('aliasedSitePath', () => { @@ -560,3 +567,151 @@ describe('getElementsAround', () => { ); }); }); + +describe('mergeTranslations', () => { + test('should merge translations', () => { + expect( + mergeTranslations([ + { + T1: {message: 'T1 message', description: 'T1 desc'}, + T2: {message: 'T2 message', description: 'T2 desc'}, + T3: {message: 'T3 message', description: 'T3 desc'}, + }, + { + T4: {message: 'T4 message', description: 'T4 desc'}, + }, + {T2: {message: 'T2 message 2', description: 'T2 desc 2'}}, + ]), + ).toEqual({ + T1: {message: 'T1 message', description: 'T1 desc'}, + T2: {message: 'T2 message 2', description: 'T2 desc 2'}, + T3: {message: 'T3 message', description: 'T3 desc'}, + T4: {message: 'T4 message', description: 'T4 desc'}, + }); + }); +}); + +describe('mapAsyncSequencial', () => { + function sleep(timeout: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeout)); + } + + test('map sequentially', async () => { + const itemToTimeout: Record = { + '1': 50, + '2': 150, + '3': 100, + }; + const items = Object.keys(itemToTimeout); + + const itemMapStartsAt: Record = {}; + const itemMapEndsAt: Record = {}; + + const timeBefore = Date.now(); + await expect( + mapAsyncSequencial(items, async (item) => { + const itemTimeout = itemToTimeout[item]; + itemMapStartsAt[item] = Date.now(); + await sleep(itemTimeout); + itemMapEndsAt[item] = Date.now(); + return `${item} mapped`; + }), + ).resolves.toEqual(['1 mapped', '2 mapped', '3 mapped']); + const timeAfter = Date.now(); + + const timeTotal = timeAfter - timeBefore; + + const totalTimeouts = sum(Object.values(itemToTimeout)); + expect(timeTotal > totalTimeouts); + + expect(itemMapStartsAt['1'] > 0); + expect(itemMapStartsAt['2'] > itemMapEndsAt['1']); + expect(itemMapStartsAt['3'] > itemMapEndsAt['2']); + }); +}); + +describe('findAsyncSequencial', () => { + function sleep(timeout: number): Promise { + return new Promise((resolve) => setTimeout(resolve, timeout)); + } + + test('find sequentially', async () => { + const items = ['1', '2', '3']; + + const findFn = jest.fn(async (item: string) => { + await sleep(50); + return item === '2'; + }); + + const timeBefore = Date.now(); + await expect(findAsyncSequential(items, findFn)).resolves.toEqual('2'); + const timeAfter = Date.now(); + + expect(findFn).toHaveBeenCalledTimes(2); + expect(findFn).toHaveBeenNthCalledWith(1, '1'); + expect(findFn).toHaveBeenNthCalledWith(2, '2'); + + const timeTotal = timeAfter - timeBefore; + expect(timeTotal > 100); + expect(timeTotal < 150); + }); +}); + +describe('findFolderContainingFile', () => { + test('find appropriate folder', async () => { + await expect( + findFolderContainingFile( + ['/abcdef', '/gehij', __dirname, '/klmn'], + 'index.test.ts', + ), + ).resolves.toEqual(__dirname); + }); + + test('return undefined if no folder contain such file', async () => { + await expect( + findFolderContainingFile(['/abcdef', '/gehij', '/klmn'], 'index.test.ts'), + ).resolves.toBeUndefined(); + }); +}); + +describe('getFolderContainingFile', () => { + test('get appropriate folder', async () => { + await expect( + getFolderContainingFile( + ['/abcdef', '/gehij', __dirname, '/klmn'], + 'index.test.ts', + ), + ).resolves.toEqual(__dirname); + }); + + test('throw if no folder contain such file', async () => { + await expect( + getFolderContainingFile(['/abcdef', '/gehij', '/klmn'], 'index.test.ts'), + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); + +describe('updateTranslationFileMessages', () => { + test('should update messages', () => { + expect( + updateTranslationFileMessages( + { + path: 'abc', + content: { + t1: {message: 't1 message', description: 't1 desc'}, + t2: {message: 't2 message', description: 't2 desc'}, + t3: {message: 't3 message', description: 't3 desc'}, + }, + }, + (message) => `prefix ${message} suffix`, + ), + ).toEqual({ + path: 'abc', + content: { + t1: {message: 'prefix t1 message suffix', description: 't1 desc'}, + t2: {message: 'prefix t2 message suffix', description: 't2 desc'}, + t3: {message: 'prefix t3 message suffix', description: 't3 desc'}, + }, + }); + }); +}); diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 5eea3925b82f..88644f48c5c0 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -14,10 +14,15 @@ import kebabCase from 'lodash.kebabcase'; import escapeStringRegexp from 'escape-string-regexp'; import fs from 'fs-extra'; import {URL} from 'url'; -import {ReportingSeverity} from '@docusaurus/types'; +import { + ReportingSeverity, + TranslationFileContent, + TranslationFile, +} from '@docusaurus/types'; // @ts-expect-error: no typedefs :s import resolvePathnameUnsafe from 'resolve-pathname'; +import {mapValues} from 'lodash'; const fileHash = new Map(); export async function generate( @@ -439,6 +444,89 @@ export function getElementsAround( return {previous, next}; } +export function getPluginI18nPath({ + siteDir, + locale, + pluginName, + pluginId = 'default', // TODO duplicated constant + subPaths = [], +}: { + siteDir: string; + locale: string; + pluginName: string; + pluginId?: string | undefined; + subPaths?: string[]; +}) { + return path.join( + siteDir, + 'i18n', + // namespace first by locale: convenient to work in a single folder for a translator + locale, + // Make it convenient to use for single-instance + // ie: return "docs", not "docs-default" nor "docs/default" + `${pluginName}${ + // TODO duplicate constant :( + pluginId === 'default' ? '' : `-${pluginId}` + }`, + ...subPaths, + ); +} + +export async function mapAsyncSequencial( + array: T[], + action: (t: T) => Promise, +): Promise { + const results: R[] = []; + for (const t of array) { + // eslint-disable-next-line no-await-in-loop + const result = await action(t); + results.push(result); + } + return results; +} + +export async function findAsyncSequential( + array: T[], + predicate: (t: T) => Promise, +): Promise { + for (const t of array) { + // eslint-disable-next-line no-await-in-loop + if (await predicate(t)) { + return t; + } + } + return undefined; +} + +// return the first folder path in which the file exists in +export async function findFolderContainingFile( + folderPaths: string[], + relativeFilePath: string, +): Promise { + return findAsyncSequential(folderPaths, (folderPath) => + fs.pathExists(path.join(folderPath, relativeFilePath)), + ); +} + +export async function getFolderContainingFile( + folderPaths: string[], + relativeFilePath: string, +): Promise { + const maybeFolderPath = await findFolderContainingFile( + folderPaths, + relativeFilePath, + ); + // should never happen, as the source was read from the FS anyway... + if (!maybeFolderPath) { + throw new Error( + `relativeFilePath=[${relativeFilePath}] does not exist in any of these folders: \n- ${folderPaths.join( + '\n- ', + )}]`, + ); + } + return maybeFolderPath; +} + export function reportMessage( message: string, reportingSeverity: ReportingSeverity, @@ -464,6 +552,14 @@ export function reportMessage( } } +export function mergeTranslations( + contents: TranslationFileContent[], +): TranslationFileContent { + return contents.reduce((acc, content) => { + return {...acc, ...content}; + }, {}); +} + export function getSwizzledComponent( componentPath: string, ): string | undefined { @@ -477,3 +573,18 @@ export function getSwizzledComponent( ? swizzledComponentPath : undefined; } + +// Useful to update all the messages of a translation file +// Used in tests to simulate translations +export function updateTranslationFileMessages( + translationFile: TranslationFile, + updateMessage: (message: string) => string, +): TranslationFile { + return { + ...translationFile, + content: mapValues(translationFile.content, (translation) => ({ + ...translation, + message: updateMessage(translation.message), + })), + }; +} diff --git a/packages/docusaurus/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js index 0603dbcfc5eb..a3168fbbee4f 100755 --- a/packages/docusaurus/bin/docusaurus.js +++ b/packages/docusaurus/bin/docusaurus.js @@ -19,6 +19,7 @@ const { externalCommand, serve, clear, + writeTranslations, } = require('../lib'); const requiredVersion = require('../package.json').engines.node; const pkg = require('../package.json'); @@ -90,14 +91,19 @@ cli '--out-dir ', 'The full path for the new output directory, relative to the current workspace (default: build).', ) + .option( + '-l, --locale ', + 'Build the site in a specified locale. Build all known locales otherwise.', + ) .option( '--no-minify', 'Build website without minimizing JS bundles (default: false)', ) - .action((siteDir = '.', {bundleAnalyzer, outDir, minify}) => { + .action((siteDir = '.', {bundleAnalyzer, outDir, locale, minify}) => { wrapCommand(build)(path.resolve(siteDir), { bundleAnalyzer, outDir, + locale, minify, }); }); @@ -123,6 +129,10 @@ cli cli .command('deploy [siteDir]') .description('Deploy website to GitHub pages') + .option( + '-l, --locale ', + 'Deploy the site in a specified locale. Deploy all known locales otherwise.', + ) .option( '--out-dir ', 'The full path for the new output directory, relative to the current workspace (default: build).', @@ -140,6 +150,7 @@ cli .description('Start the development server') .option('-p, --port ', 'use specified port (default: 3000)') .option('-h, --host ', 'use specified host (default: localhost') + .option('-l, --locale ', 'use specified site locale') .option( '--hot-only', 'Do not fallback to page refresh if hot reload fails (default: false)', @@ -149,10 +160,11 @@ cli '--poll [interval]', 'Use polling rather than watching for reload (default: false). Can specify a poll interval in milliseconds.', ) - .action((siteDir = '.', {port, host, hotOnly, open, poll}) => { + .action((siteDir = '.', {port, host, locale, hotOnly, open, poll}) => { wrapCommand(start)(path.resolve(siteDir), { port, host, + locale, hotOnly, open, poll, @@ -189,12 +201,40 @@ cli ); cli - .command('clear') + .command('clear [siteDir]') .description('Remove build artifacts') - .action(() => { - wrapCommand(clear)(path.resolve('.')); + .action((siteDir = '.') => { + wrapCommand(clear)(path.resolve(siteDir)); }); +cli + .command('write-translations [siteDir]') + .description('Extract required translations of your site') + .option( + '-l, --locale ', + 'The locale folder to write the translations\n"--locale fr" will write translations in ./i18n/fr folder)', + ) + .option( + '--override', + 'By default, we only append missing translation messages to existing translation files. This option allows to override existing translation messages. Make sure to commit or backup your existing translations, as they may be overridden.', + ) + .option( + '--messagePrefix ', + 'Allows to init new written messages with a given prefix. This might help you to highlight untranslated message to make them stand out in the UI.', + ) + .action( + ( + siteDir = '.', + {locale = undefined, override = false, messagePrefix = ''}, + ) => { + wrapCommand(writeTranslations)(path.resolve(siteDir), { + locale, + override, + messagePrefix, + }); + }, + ); + cli.arguments('').action((cmd) => { cli.outputHelp(); console.log(` ${chalk.red(`\n Unknown command ${chalk.yellow(cmd)}.`)}`); @@ -202,9 +242,15 @@ cli.arguments('').action((cmd) => { }); function isInternalCommand(command) { - return ['start', 'build', 'swizzle', 'deploy', 'serve', 'clear'].includes( - command, - ); + return [ + 'start', + 'build', + 'swizzle', + 'deploy', + 'serve', + 'clear', + 'write-translations', + ].includes(command); } if (!isInternalCommand(process.argv.slice(2)[0])) { diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 61f7c6d5e730..0f51f97c63c7 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -32,10 +32,12 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "2.0.0-alpha.69", - "@types/detect-port": "^1.3.0" + "@types/detect-port": "^1.3.0", + "tmp-promise": "^3.0.2" }, "dependencies": { "@babel/core": "^7.12.3", + "@babel/generator": "^7.12.5", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", "@babel/plugin-proposal-optional-chaining": "^7.12.1", "@babel/plugin-syntax-dynamic-import": "^7.8.3", @@ -45,6 +47,7 @@ "@babel/preset-typescript": "^7.12.1", "@babel/runtime": "^7.12.5", "@babel/runtime-corejs3": "^7.12.5", + "@babel/traverse": "^7.12.5", "@docusaurus/cssnano-preset": "2.0.0-alpha.69", "@docusaurus/types": "2.0.0-alpha.69", "@docusaurus/utils": "2.0.0-alpha.69", diff --git a/packages/docusaurus/src/babel/preset.ts b/packages/docusaurus/src/babel/preset.ts index 77acf77d0cf2..f85fe1c2eab1 100644 --- a/packages/docusaurus/src/babel/preset.ts +++ b/packages/docusaurus/src/babel/preset.ts @@ -79,4 +79,4 @@ function babelPresets(api: ConfigAPI): TransformOptions { return getTransformOptions(callerName === 'server'); } -export = babelPresets; +export default babelPresets; diff --git a/packages/docusaurus/src/client/App.tsx b/packages/docusaurus/src/client/App.tsx index f30d37a7c24a..5a22629ab03a 100644 --- a/packages/docusaurus/src/client/App.tsx +++ b/packages/docusaurus/src/client/App.tsx @@ -10,6 +10,8 @@ import React, {useEffect, useState} from 'react'; import routes from '@generated/routes'; import siteConfig from '@generated/docusaurus.config'; import globalData from '@generated/globalData'; +import i18n from '@generated/i18n'; +import codeTranslations from '@generated/codeTranslations'; import siteMetadata from '@generated/site-metadata'; import renderRoutes from './exports/renderRoutes'; import DocusaurusContext from './exports/context'; @@ -27,7 +29,14 @@ function App(): JSX.Element { return ( + value={{ + siteConfig, + siteMetadata, + globalData, + i18n, + codeTranslations, + isClient, + }}> {renderRoutes(routes)} diff --git a/packages/docusaurus/src/client/exports/Translate.tsx b/packages/docusaurus/src/client/exports/Translate.tsx new file mode 100644 index 000000000000..d5ff9bff1fff --- /dev/null +++ b/packages/docusaurus/src/client/exports/Translate.tsx @@ -0,0 +1,48 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; + +// Can't read it from context, due to exposing imperative API +import codeTranslations from '@generated/codeTranslations'; + +function getLocalizedMessage({ + id, + message, +}: { + message: string; + id?: string; +}): string { + return codeTranslations[id ?? message] ?? message; +} + +export type TranslateParam = { + message: string; + id?: string; + description?: string; +}; +// Imperative translation API is useful for some edge-cases: +// - translating page titles (meta) +// - translating string props (input placeholders, image alt, aria labels...) +export function translate({message, id}: TranslateParam): string { + const localizedMessage = getLocalizedMessage({message, id}); + return localizedMessage ?? message; +} + +export type TranslateProps = { + children: string; + id?: string; + description?: string; +}; + +// Maybe we'll want to improve this component with additional features +// Like toggling a translation mode that adds a little translation button near the text? +export default function Translate({children, id}: TranslateProps): JSX.Element { + const localizedMessage: string = + getLocalizedMessage({message: children, id}) ?? children; + return <>{localizedMessage}; +} diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index 166e42852732..f410e11aa4b1 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -22,17 +22,76 @@ import createClientConfig from '../webpack/client'; import createServerConfig from '../webpack/server'; import {compile, applyConfigureWebpack} from '../webpack/utils'; import CleanWebpackPlugin from '../webpack/plugins/CleanWebpackPlugin'; +import {loadI18n} from '../server/i18n'; +import {mapAsyncSequencial} from '@docusaurus/utils'; +import loadConfig from '../server/config'; export default async function build( siteDir: string, cliOptions: Partial = {}, forceTerminate: boolean = true, +): Promise { + async function tryToBuildLocale(locale: string, forceTerm) { + try { + const result = await buildLocale(siteDir, locale, cliOptions, forceTerm); + console.log(chalk.green(`Site successfully built in locale=${locale}`)); + return result; + } catch (e) { + console.error(`error building locale=${locale}`); + throw e; + } + } + + const i18n = await loadI18n(loadConfig(siteDir), { + locale: cliOptions.locale, + }); + if (cliOptions.locale) { + return tryToBuildLocale(cliOptions.locale, forceTerminate); + } else { + if (i18n.locales.length > 1) { + console.log( + chalk.yellow( + `\nSite will be built with all these locales: +- ${i18n.locales.join('\n- ')}\n`, + ), + ); + } + + // We need the default locale to always be the 1st in the list + // If we build it last, it would "erase" the localized sites built in subfolders + const orderedLocales: string[] = [ + i18n.defaultLocale, + ...i18n.locales.filter((locale) => locale !== i18n.defaultLocale), + ]; + + const results = await mapAsyncSequencial(orderedLocales, (locale) => { + const isLastLocale = + i18n.locales.indexOf(locale) === i18n.locales.length - 1; + // TODO check why we need forceTerminate + const forceTerm = isLastLocale && forceTerminate; + return tryToBuildLocale(locale, forceTerm); + }); + return results[0]!; + } +} + +async function buildLocale( + siteDir: string, + locale: string, + cliOptions: Partial = {}, + forceTerminate: boolean = true, ): Promise { process.env.BABEL_ENV = 'production'; process.env.NODE_ENV = 'production'; - console.log(chalk.blue('Creating an optimized production build...')); + console.log( + chalk.blue(`[${locale}] Creating an optimized production build...`), + ); - const props: Props = await load(siteDir, cliOptions.outDir); + const props: Props = await load(siteDir, { + customOutDir: cliOptions.outDir, + locale, + localizePath: cliOptions.locale ? false : undefined, + }); // Apply user webpack config. const { diff --git a/packages/docusaurus/src/commands/deploy.ts b/packages/docusaurus/src/commands/deploy.ts index 5690bdb13994..590205c791e7 100644 --- a/packages/docusaurus/src/commands/deploy.ts +++ b/packages/docusaurus/src/commands/deploy.ts @@ -18,7 +18,9 @@ export default async function deploy( siteDir: string, cliOptions: Partial = {}, ): Promise { - const {outDir} = loadContext(siteDir, cliOptions.outDir); + const {outDir} = await loadContext(siteDir, { + customOutDir: cliOptions.outDir, + }); const tempDir = path.join(siteDir, GENERATED_FILES_DIR_NAME); console.log('Deploy command invoked ...'); diff --git a/packages/docusaurus/src/commands/external.ts b/packages/docusaurus/src/commands/external.ts index 7ee80ccefbbb..0d00c3fcb7f6 100644 --- a/packages/docusaurus/src/commands/external.ts +++ b/packages/docusaurus/src/commands/external.ts @@ -9,11 +9,11 @@ import {CommanderStatic} from 'commander'; import {loadContext, loadPluginConfigs} from '../server'; import initPlugins from '../server/plugins/init'; -export default function externalCommand( +export default async function externalCommand( cli: CommanderStatic, siteDir: string, -): void { - const context = loadContext(siteDir); +): Promise { + const context = await loadContext(siteDir); const pluginConfigs = loadPluginConfigs(context); const plugins = initPlugins({pluginConfigs, context}); diff --git a/packages/docusaurus/src/commands/start.ts b/packages/docusaurus/src/commands/start.ts index 25d07444e895..01482808e099 100644 --- a/packages/docusaurus/src/commands/start.ts +++ b/packages/docusaurus/src/commands/start.ts @@ -25,6 +25,7 @@ import {CONFIG_FILE_NAME, STATIC_DIR_NAME} from '../constants'; import createClientConfig from '../webpack/client'; import {applyConfigureWebpack, getHttpsConfig} from '../webpack/utils'; import {getCLIOptionHost, getCLIOptionPort} from './commandUtils'; +import {getTranslationsLocaleDirPath} from '../server/translations/translations'; export default async function start( siteDir: string, @@ -34,8 +35,15 @@ export default async function start( process.env.BABEL_ENV = 'development'; console.log(chalk.blue('Starting the development server...')); + function loadSite() { + return load(siteDir, { + locale: cliOptions.locale, + localizePath: undefined, // should this be configurable? + }); + } + // Process all related files as a prop. - const props = await load(siteDir); + const props = await loadSite(); const protocol: string = process.env.HTTPS === 'true' ? 'https' : 'http'; @@ -54,7 +62,7 @@ export default async function start( // Reload files processing. const reload = () => { - load(siteDir) + loadSite() .then(({baseUrl: newBaseUrl}) => { const newOpenUrl = normalizeUrl([urls.localUrlForBrowser, newBaseUrl]); console.log( @@ -82,7 +90,16 @@ export default async function start( ) .map(normalizeToSiteDir); - const fsWatcher = chokidar.watch([...pluginPaths, CONFIG_FILE_NAME], { + const pathsToWatch: string[] = [ + ...pluginPaths, + CONFIG_FILE_NAME, + getTranslationsLocaleDirPath({ + siteDir, + locale: props.i18n.currentLocale, + }), + ]; + + const fsWatcher = chokidar.watch(pathsToWatch, { cwd: siteDir, ignoreInitial: true, usePolling: !!cliOptions.poll, diff --git a/packages/docusaurus/src/commands/swizzle.ts b/packages/docusaurus/src/commands/swizzle.ts index 2ffc038ccbe6..e8cc32e533d1 100644 --- a/packages/docusaurus/src/commands/swizzle.ts +++ b/packages/docusaurus/src/commands/swizzle.ts @@ -130,7 +130,7 @@ export default async function swizzle( typescript?: boolean, danger?: boolean, ): Promise { - const context = loadContext(siteDir); + const context = await loadContext(siteDir); const pluginConfigs = loadPluginConfigs(context); const pluginNames = getPluginNames(pluginConfigs); const plugins = initPlugins({ diff --git a/packages/docusaurus/src/commands/writeTranslations.ts b/packages/docusaurus/src/commands/writeTranslations.ts new file mode 100644 index 000000000000..1c33f11f06cf --- /dev/null +++ b/packages/docusaurus/src/commands/writeTranslations.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {loadContext, loadPluginConfigs} from '../server'; +import initPlugins, {InitPlugin} from '../server/plugins/init'; + +import { + writePluginTranslations, + writeCodeTranslations, + WriteTranslationsOptions, +} from '../server/translations/translations'; +import {extractPluginsSourceCodeTranslations} from '../server/translations/translationsExtractor'; +import {getCustomBabelConfigFilePath, getBabelOptions} from '../webpack/utils'; + +async function writePluginTranslationFiles({ + siteDir, + plugin, + locale, + options, +}: { + siteDir: string; + plugin: InitPlugin; + locale: string; + options: WriteTranslationsOptions; +}) { + if (plugin.getTranslationFiles) { + const translationFiles = await plugin.getTranslationFiles(); + + await Promise.all( + translationFiles.map(async (translationFile) => { + await writePluginTranslations({ + siteDir, + plugin, + translationFile, + locale, + options, + }); + }), + ); + } +} + +export default async function writeTranslations( + siteDir: string, + options: WriteTranslationsOptions & {locale?: string}, +): Promise { + const context = await loadContext(siteDir); + const pluginConfigs = loadPluginConfigs(context); + const plugins = initPlugins({ + pluginConfigs, + context, + }); + + const locale = options.locale ?? context.i18n.defaultLocale; + + if (!context.i18n.locales.includes(locale)) { + throw new Error( + `Can't write-translation for locale that is not in the locale configuration file. +Unknown locale=[${locale}]. +Available locales=[${context.i18n.locales.join(',')}]`, + ); + } + + const babelOptions = getBabelOptions({ + isServer: true, + babelOptions: getCustomBabelConfigFilePath(siteDir), + }); + const codeTranslations = await extractPluginsSourceCodeTranslations( + plugins, + babelOptions, + ); + await writeCodeTranslations({siteDir, locale}, codeTranslations, options); + + await Promise.all( + plugins.map(async (plugin) => { + await writePluginTranslationFiles({siteDir, plugin, locale, options}); + }), + ); +} diff --git a/packages/docusaurus/src/index.ts b/packages/docusaurus/src/index.ts index 2c9ca7bd03f8..f2c15ce9789c 100644 --- a/packages/docusaurus/src/index.ts +++ b/packages/docusaurus/src/index.ts @@ -12,3 +12,4 @@ export {default as deploy} from './commands/deploy'; export {default as externalCommand} from './commands/external'; export {default as serve} from './commands/serve'; export {default as clear} from './commands/clear'; +export {default as writeTranslations} from './commands/writeTranslations'; diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index 3d2cc5e1e4b5..f68ff8b45978 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -21,6 +21,12 @@ Object { "baseUrlIssueBanner": true, "customFields": Object {}, "favicon": "img/docusaurus.ico", + "i18n": Object { + "defaultLocale": "en", + "locales": Array [ + "en", + ], + }, "noIndex": false, "onBrokenLinks": "throw", "onBrokenMarkdownLinks": "warn", diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap new file mode 100644 index 000000000000..af4ea6e43d0e --- /dev/null +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`loadI18n should throw when trying to load undeclared locale 1`] = ` +"It is not possible to load Docusaurus with locale=\\"it\\". +This locale is not in the available locales of your site configuration: config.i18n.locales=[en,fr,de] +Note: Docusaurus only support running one local at a time." +`; diff --git a/packages/docusaurus/src/server/__tests__/i18n.test.ts b/packages/docusaurus/src/server/__tests__/i18n.test.ts new file mode 100644 index 000000000000..39ab34c4b327 --- /dev/null +++ b/packages/docusaurus/src/server/__tests__/i18n.test.ts @@ -0,0 +1,156 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {loadI18n, localizePath} from '../i18n'; +import {DEFAULT_I18N_CONFIG} from '../configValidation'; +import path from 'path'; + +describe('loadI18n', () => { + test('should load I18n for default config', async () => { + await expect( + loadI18n( + // @ts-expect-error: enough for this test + { + i18n: DEFAULT_I18N_CONFIG, + }, + ), + ).resolves.toEqual({ + defaultLocale: 'en', + locales: ['en'], + currentLocale: 'en', + }); + }); + + test('should load I18n for multi-lang config', async () => { + await expect( + loadI18n( + // @ts-expect-error: enough for this test + { + i18n: { + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + }, + }, + ), + ).resolves.toEqual({ + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + currentLocale: 'fr', + }); + }); + + test('should load I18n for multi-locale config with specified locale', async () => { + await expect( + loadI18n( + // @ts-expect-error: enough for this test + { + i18n: { + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + }, + }, + {locale: 'de'}, + ), + ).resolves.toEqual({ + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + currentLocale: 'de', + }); + }); + + test('should throw when trying to load undeclared locale', async () => { + await expect( + loadI18n( + // @ts-expect-error: enough for this test + { + i18n: { + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + }, + }, + {locale: 'it'}, + ), + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); + +describe('localizePath', () => { + test('should localize url path with current locale', () => { + expect( + localizePath({ + pathType: 'url', + path: '/baseUrl', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + currentLocale: 'fr', + }, + options: {localizePath: true}, + }), + ).toEqual('/baseUrl/fr/'); + }); + + test('should localize fs path with current locale', () => { + expect( + localizePath({ + pathType: 'fs', + path: '/baseFsPath', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + currentLocale: 'fr', + }, + options: {localizePath: true}, + }), + ).toEqual(`/baseFsPath${path.sep}fr${path.sep}`); + }); + + test('should localize path for default locale, if requested', () => { + expect( + localizePath({ + pathType: 'url', + path: '/baseUrl/', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + currentLocale: 'en', + }, + options: {localizePath: true}, + }), + ).toEqual('/baseUrl/en/'); + }); + + test('should not localize path for default locale by default', () => { + expect( + localizePath({ + pathType: 'url', + path: '/baseUrl/', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + currentLocale: 'en', + }, + // options: {localizePath: true}, + }), + ).toEqual('/baseUrl/'); + }); + + test('should localize path for non-default locale by default', () => { + expect( + localizePath({ + pathType: 'url', + path: '/baseUrl/', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + currentLocale: 'en', + }, + // options: {localizePath: true}, + }), + ).toEqual('/baseUrl/'); + }); +}); diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index dc1ce1514b34..177dd9a0890e 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {DocusaurusConfig} from '@docusaurus/types'; +import {DocusaurusConfig, I18nConfig} from '@docusaurus/types'; import {CONFIG_FILE_NAME} from '../constants'; import Joi from 'joi'; import { @@ -14,8 +14,16 @@ import { URISchema, } from '@docusaurus/utils-validation'; +const DEFAULT_I18N_LOCALE = 'en'; + +export const DEFAULT_I18N_CONFIG: I18nConfig = { + defaultLocale: DEFAULT_I18N_LOCALE, + locales: [DEFAULT_I18N_LOCALE], +}; + export const DEFAULT_CONFIG: Pick< DocusaurusConfig, + | 'i18n' | 'onBrokenLinks' | 'onBrokenMarkdownLinks' | 'onDuplicateRoutes' @@ -28,6 +36,7 @@ export const DEFAULT_CONFIG: Pick< | 'noIndex' | 'baseUrlIssueBanner' > = { + i18n: DEFAULT_I18N_CONFIG, onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', onDuplicateRoutes: 'warn', @@ -57,6 +66,13 @@ const PresetSchema = Joi.alternatives().try( Joi.array().items(Joi.string().required(), Joi.object().required()).length(2), ); +const I18N_CONFIG_SCHEMA = Joi.object({ + defaultLocale: Joi.string().required(), + locales: Joi.array().items().min(1).items(Joi.string().required()).required(), +}) + .optional() + .default(DEFAULT_I18N_CONFIG); + // TODO move to @docusaurus/utils-validation const ConfigSchema = Joi.object({ baseUrl: Joi.string() @@ -67,6 +83,7 @@ const ConfigSchema = Joi.object({ favicon: Joi.string().required(), title: Joi.string().required(), url: URISchema.required(), + i18n: I18N_CONFIG_SCHEMA, onBrokenLinks: Joi.string() .equal('ignore', 'log', 'warn', 'error', 'throw') .default(DEFAULT_CONFIG.onBrokenLinks), diff --git a/packages/docusaurus/src/server/i18n.ts b/packages/docusaurus/src/server/i18n.ts new file mode 100644 index 000000000000..636b639f2ff6 --- /dev/null +++ b/packages/docusaurus/src/server/i18n.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import {I18n, DocusaurusConfig} from '@docusaurus/types'; +import path from 'path'; +import {normalizeUrl} from '@docusaurus/utils'; + +export async function loadI18n( + config: DocusaurusConfig, + options: {locale?: string} = {}, +): Promise { + const i18nConfig = config.i18n; + const currentLocale = options.locale ?? i18nConfig.defaultLocale; + + if (currentLocale && !i18nConfig.locales.includes(currentLocale)) { + throw new Error( + `It is not possible to load Docusaurus with locale="${currentLocale}". +This locale is not in the available locales of your site configuration: config.i18n.locales=[${i18nConfig.locales.join( + ',', + )}] +Note: Docusaurus only support running one local at a time.`, + ); + } + + return { + ...i18nConfig, + currentLocale, + }; +} + +export function localizePath({ + pathType, + path: originalPath, + i18n, + options = {}, +}: { + pathType: 'fs' | 'url'; + path: string; + i18n: I18n; + options?: {localizePath?: boolean}; +}): string { + const shouldLocalizePath: boolean = + typeof options.localizePath === 'undefined' + ? // By default, we don't localize the path of defaultLocale + i18n.currentLocale !== i18n.defaultLocale + : options.localizePath; + + if (shouldLocalizePath) { + // FS paths need special care, for Windows support + if (pathType === 'fs') { + return path.join(originalPath, path.sep, i18n.currentLocale, path.sep); + } + // Url paths + else if (pathType === 'url') { + return normalizeUrl([originalPath, '/', i18n.currentLocale, '/']); + } + // should never happen + else { + throw new Error(`unhandled pathType=${pathType}`); + } + } else { + return originalPath; + } +} diff --git a/packages/docusaurus/src/server/index.ts b/packages/docusaurus/src/server/index.ts index bcd2c84843a3..2bba8118413c 100644 --- a/packages/docusaurus/src/server/index.ts +++ b/packages/docusaurus/src/server/index.ts @@ -7,6 +7,7 @@ import {generate} from '@docusaurus/utils'; import path, {join} from 'path'; +import chalk from 'chalk'; import ssrDefaultTemplate from '../client/templates/ssr.html.template'; import { BUILD_DIR_NAME, @@ -30,21 +31,60 @@ import { import {loadHtmlTags} from './html-tags'; import {getPackageJsonVersion} from './versions'; import {handleDuplicateRoutes} from './duplicateRoutes'; -import chalk from 'chalk'; +import {loadI18n, localizePath} from './i18n'; +import {readCodeTranslationFileContent} from './translations/translations'; +import {mapValues} from 'lodash'; -export function loadContext( +type LoadContextOptions = { + customOutDir?: string; + locale?: string; + localizePath?: boolean; // undefined = only non-default locales paths are localized +}; + +export async function loadContext( siteDir: string, - customOutDir?: string, -): LoadContext { + options: LoadContextOptions = {}, +): Promise { + const {customOutDir, locale} = options; const generatedFilesDir: string = path.resolve( siteDir, GENERATED_FILES_DIR_NAME, ); - const siteConfig: DocusaurusConfig = loadConfig(siteDir); - const outDir = customOutDir + const initialSiteConfig: DocusaurusConfig = loadConfig(siteDir); + const {ssrTemplate} = initialSiteConfig; + + const baseOutDir = customOutDir ? path.resolve(customOutDir) : path.resolve(siteDir, BUILD_DIR_NAME); - const {baseUrl, ssrTemplate} = siteConfig; + + const i18n = await loadI18n(initialSiteConfig, {locale}); + + const baseUrl = localizePath({ + path: initialSiteConfig.baseUrl, + i18n, + options, + pathType: 'url', + }); + const outDir = localizePath({ + path: baseOutDir, + i18n, + options, + pathType: 'fs', + }); + + const siteConfig: DocusaurusConfig = {...initialSiteConfig, baseUrl}; + + const codeTranslationFileContent = + (await readCodeTranslationFileContent({ + siteDir, + locale: i18n.currentLocale, + })) ?? {}; + + // We only need key->message for code translations + const codeTranslations = mapValues( + codeTranslationFileContent, + (value) => value.message, + ); return { siteDir, @@ -52,7 +92,9 @@ export function loadContext( siteConfig, outDir, baseUrl, + i18n, ssrTemplate, + codeTranslations, }; } @@ -70,19 +112,34 @@ export function loadPluginConfigs(context: LoadContext): PluginConfig[] { export async function load( siteDir: string, - customOutDir?: string, + options: LoadContextOptions = {}, ): Promise { // Context. - const context: LoadContext = loadContext(siteDir, customOutDir); - const {generatedFilesDir, siteConfig, outDir, baseUrl, ssrTemplate} = context; - + const context: LoadContext = await loadContext(siteDir, options); + const { + generatedFilesDir, + siteConfig, + outDir, + baseUrl, + i18n, + ssrTemplate, + codeTranslations, + } = context; // Plugins. const pluginConfigs: PluginConfig[] = loadPluginConfigs(context); - const {plugins, pluginsRouteConfigs, globalData} = await loadPlugins({ + const { + plugins, + pluginsRouteConfigs, + globalData, + themeConfigTranslated, + } = await loadPlugins({ pluginConfigs, context, }); + // Side-effect to replace the untranslated themeConfig by the translated one + context.siteConfig.themeConfig = themeConfigTranslated; + handleDuplicateRoutes(pluginsRouteConfigs, siteConfig.onDuplicateRoutes); // Site config must be generated after plugins @@ -204,6 +261,18 @@ ${Object.keys(registry) JSON.stringify(globalData, null, 2), ); + const genI18n = generate( + generatedFilesDir, + 'i18n.json', + JSON.stringify(i18n, null, 2), + ); + + const genCodeTranslations = generate( + generatedFilesDir, + 'codeTranslations.json', + JSON.stringify(codeTranslations, null, 2), + ); + // Version metadata. const siteMetadata: DocusaurusSiteMetadata = { docusaurusVersion: getPackageJsonVersion( @@ -232,6 +301,8 @@ ${Object.keys(registry) genRoutes, genGlobalData, genSiteMetadata, + genI18n, + genCodeTranslations, ]); const props: Props = { @@ -239,6 +310,7 @@ ${Object.keys(registry) siteDir, outDir, baseUrl, + i18n, generatedFilesDir, routes: pluginsRouteConfigs, routesPaths, @@ -247,6 +319,7 @@ ${Object.keys(registry) preBodyTags, postBodyTags, ssrTemplate: ssrTemplate || ssrDefaultTemplate, + codeTranslations, }; return props; diff --git a/packages/docusaurus/src/server/plugins/index.ts b/packages/docusaurus/src/server/plugins/index.ts index 8d3306e3a194..32bb7ec44a86 100644 --- a/packages/docusaurus/src/server/plugins/index.ts +++ b/packages/docusaurus/src/server/plugins/index.ts @@ -9,16 +9,19 @@ import {generate} from '@docusaurus/utils'; import fs from 'fs-extra'; import path from 'path'; import { - AllContent, LoadContext, PluginConfig, PluginContentLoadedActions, RouteConfig, + AllContent, + TranslationFiles, + ThemeConfig, } from '@docusaurus/types'; import initPlugins, {InitPlugin} from './init'; import chalk from 'chalk'; import {DEFAULT_PLUGIN_ID} from '../../constants'; import {chain} from 'lodash'; +import {localizePluginTranslationFile} from '../translations/translations'; export function sortConfig(routeConfigs: RouteConfig[]): void { // Sort the route config. This ensures that route with nested @@ -49,6 +52,14 @@ export function sortConfig(routeConfigs: RouteConfig[]): void { }); } +export type AllPluginsTranslationFiles = Record< + string, // plugin name + Record< + string, // plugin id + TranslationFiles + > +>; + export async function loadPlugins({ pluginConfigs, context, @@ -59,6 +70,7 @@ export async function loadPlugins({ plugins: InitPlugin[]; pluginsRouteConfigs: RouteConfig[]; globalData: any; + themeConfigTranslated: ThemeConfig; }> { // 1. Plugin Lifecycle - Initialization/Constructor. const plugins: InitPlugin[] = initPlugins({ @@ -78,6 +90,30 @@ export async function loadPlugins({ }), ); + type ContentLoadedTranslatedPlugin = ContentLoadedPlugin & { + translationFiles: TranslationFiles; + }; + const contentLoadedTranslatedPlugins: ContentLoadedTranslatedPlugin[] = await Promise.all( + contentLoadedPlugins.map(async (contentLoadedPlugin) => { + const translationFiles = + (await contentLoadedPlugin.plugin?.getTranslationFiles?.()) ?? []; + const localizedTranslationFiles = await Promise.all( + translationFiles.map((translationFile) => + localizePluginTranslationFile({ + locale: context.i18n.currentLocale, + siteDir: context.siteDir, + translationFile, + plugin: contentLoadedPlugin.plugin, + }), + ), + ); + return { + ...contentLoadedPlugin, + translationFiles: localizedTranslationFiles, + }; + }), + ); + const allContent: AllContent = chain(contentLoadedPlugins) .groupBy((item) => item.plugin.name) .mapValues((nameItems) => { @@ -94,52 +130,57 @@ export async function loadPlugins({ const globalData = {}; await Promise.all( - contentLoadedPlugins.map(async ({plugin, content}) => { - if (!plugin.contentLoaded) { - return; - } - - const pluginId = plugin.options.id ?? DEFAULT_PLUGIN_ID; - - // plugins data files are namespaced by pluginName/pluginId - const dataDirRoot = path.join(context.generatedFilesDir, plugin.name); - const dataDir = path.join(dataDirRoot, pluginId); - - const addRoute: PluginContentLoadedActions['addRoute'] = (config) => - pluginsRouteConfigs.push(config); - - const createData: PluginContentLoadedActions['createData'] = async ( - name, - data, - ) => { - const modulePath = path.join(dataDir, name); - await fs.ensureDir(path.dirname(modulePath)); - await generate(dataDir, name, data); - return modulePath; - }; - - // the plugins global data are namespaced to avoid data conflicts: - // - by plugin name - // - by plugin id (allow using multiple instances of the same plugin) - const setGlobalData: PluginContentLoadedActions['setGlobalData'] = ( - data, - ) => { - globalData[plugin.name] = globalData[plugin.name] ?? {}; - globalData[plugin.name][pluginId] = data; - }; - - const actions: PluginContentLoadedActions = { - addRoute, - createData, - setGlobalData, - }; - - await plugin.contentLoaded({ - content, - actions, - allContent, - }); - }), + contentLoadedTranslatedPlugins.map( + async ({plugin, content, translationFiles}) => { + if (!plugin.contentLoaded) { + return; + } + + const pluginId = plugin.options.id ?? DEFAULT_PLUGIN_ID; + + // plugins data files are namespaced by pluginName/pluginId + const dataDirRoot = path.join(context.generatedFilesDir, plugin.name); + const dataDir = path.join(dataDirRoot, pluginId); + + const addRoute: PluginContentLoadedActions['addRoute'] = (config) => + pluginsRouteConfigs.push(config); + + const createData: PluginContentLoadedActions['createData'] = async ( + name, + data, + ) => { + const modulePath = path.join(dataDir, name); + await fs.ensureDir(path.dirname(modulePath)); + await generate(dataDir, name, data); + return modulePath; + }; + + // the plugins global data are namespaced to avoid data conflicts: + // - by plugin name + // - by plugin id (allow using multiple instances of the same plugin) + const setGlobalData: PluginContentLoadedActions['setGlobalData'] = ( + data, + ) => { + globalData[plugin.name] = globalData[plugin.name] ?? {}; + globalData[plugin.name][pluginId] = data; + }; + + const actions: PluginContentLoadedActions = { + addRoute, + createData, + setGlobalData, + }; + + const translatedContent = + plugin.translateContent?.({content, translationFiles}) ?? content; + + await plugin.contentLoaded({ + content: translatedContent, + actions, + allContent, + }); + }, + ), ); // 4. Plugin Lifecycle - routesLoaded. @@ -147,7 +188,7 @@ export async function loadPlugins({ // We could change this in future if there are plugins which need to // run in certain order or depend on others for data. await Promise.all( - plugins.map(async (plugin) => { + contentLoadedTranslatedPlugins.map(async ({plugin}) => { if (!plugin.routesLoaded) { return null; } @@ -168,9 +209,29 @@ export async function loadPlugins({ // routes are always placed last. sortConfig(pluginsRouteConfigs); + // Apply each plugin one after the other to translate the theme config + function translateThemeConfig( + untranslatedThemeConfig: ThemeConfig, + ): ThemeConfig { + return contentLoadedTranslatedPlugins.reduce( + (currentThemeConfig, {plugin, translationFiles}) => { + const translatedThemeConfigSlice = plugin.translateThemeConfig?.({ + themeConfig: currentThemeConfig, + translationFiles, + }); + return { + ...currentThemeConfig, + ...translatedThemeConfigSlice, + }; + }, + untranslatedThemeConfig, + ); + } + return { plugins, pluginsRouteConfigs, globalData, + themeConfigTranslated: translateThemeConfig(context.siteConfig.themeConfig), }; } diff --git a/packages/docusaurus/src/server/translations/__tests__/translations.test.ts b/packages/docusaurus/src/server/translations/__tests__/translations.test.ts new file mode 100644 index 000000000000..319187aa8b44 --- /dev/null +++ b/packages/docusaurus/src/server/translations/__tests__/translations.test.ts @@ -0,0 +1,465 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + ensureTranslationFileContent, + writeTranslationFileContent, + writePluginTranslations, + readTranslationFileContent, + WriteTranslationsOptions, + localizePluginTranslationFile, +} from '../translations'; +import fs from 'fs-extra'; +import tmp from 'tmp-promise'; +import {TranslationFile, TranslationFileContent} from '@docusaurus/types'; +import path from 'path'; + +async function createTmpSiteDir() { + const {path: siteDirPath} = await tmp.dir({ + prefix: 'jest-createTmpSiteDir', + }); + return siteDirPath; +} + +async function createTmpTranslationFile( + content: TranslationFileContent | null, +) { + const file = await tmp.file({ + prefix: 'jest-createTmpTranslationFile', + postfix: '.json', + }); + + // null means we don't want a file, but tmp.file() creates an empty file :( + if (content === null) { + await fs.unlink(file.path); + } else { + await fs.writeFile(file.path, JSON.stringify(content, null, 2)); + } + + return { + filePath: file.path, + readFile: async () => JSON.parse(await fs.readFile(file.path, 'utf8')), + }; +} + +describe('ensureTranslationFileContent', () => { + test('should pass valid translation file content', () => { + ensureTranslationFileContent({}); + ensureTranslationFileContent({key1: {message: ''}}); + ensureTranslationFileContent({key1: {message: 'abc'}}); + ensureTranslationFileContent({key1: {message: 'abc', description: 'desc'}}); + ensureTranslationFileContent({ + key1: {message: 'abc', description: 'desc'}, + key2: {message: 'def', description: 'desc'}, + }); + }); + + test('should fail for invalid translation file content', () => { + expect(() => + ensureTranslationFileContent(null), + ).toThrowErrorMatchingInlineSnapshot( + `"\\"value\\" must be of type object"`, + ); + expect(() => + ensureTranslationFileContent(undefined), + ).toThrowErrorMatchingInlineSnapshot(`"\\"value\\" is required"`); + expect(() => + ensureTranslationFileContent('HEY'), + ).toThrowErrorMatchingInlineSnapshot( + `"\\"value\\" must be of type object"`, + ); + expect(() => + ensureTranslationFileContent(42), + ).toThrowErrorMatchingInlineSnapshot( + `"\\"value\\" must be of type object"`, + ); + expect(() => + ensureTranslationFileContent({key: {description: 'no message'}}), + ).toThrowErrorMatchingInlineSnapshot(`"\\"key.message\\" is required"`); + expect(() => + ensureTranslationFileContent({key: {message: 42}}), + ).toThrowErrorMatchingInlineSnapshot( + `"\\"key.message\\" must be a string"`, + ); + expect(() => + ensureTranslationFileContent({ + key: {message: 'Message', description: 42}, + }), + ).toThrowErrorMatchingInlineSnapshot( + `"\\"key.description\\" must be a string"`, + ); + }); +}); + +describe('writeTranslationFileContent', () => { + test('should create new translation file', async () => { + const {filePath, readFile} = await createTmpTranslationFile(null); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }); + }); + + test('should create new translation file with prefix', async () => { + const {filePath, readFile} = await createTmpTranslationFile(null); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }, + options: { + messagePrefix: 'PREFIX ', + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'PREFIX key1 message'}, + key2: {message: 'PREFIX key2 message'}, + key3: {message: 'PREFIX key3 message'}, + }); + }); + + test('should append missing translations', async () => { + const {filePath, readFile} = await createTmpTranslationFile({ + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message new'}, + key2: {message: 'key2 message new'}, + key3: {message: 'key3 message new'}, + key4: {message: 'key4 message new'}, + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + key4: {message: 'key4 message new'}, + }); + }); + + test('should append missing translations with prefix', async () => { + const {filePath, readFile} = await createTmpTranslationFile({ + key1: {message: 'key1 message'}, + }); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message new'}, + key2: {message: 'key2 message new'}, + }, + options: { + messagePrefix: 'PREFIX ', + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'key1 message'}, + key2: {message: 'PREFIX key2 message new'}, + }); + }); + + test('should override missing translations', async () => { + const {filePath, readFile} = await createTmpTranslationFile({ + key1: {message: 'key1 message'}, + }); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message new'}, + key2: {message: 'key2 message new'}, + }, + options: { + override: true, + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'key1 message new'}, + key2: {message: 'key2 message new'}, + }); + }); + + test('should override missing translations with prefix', async () => { + const {filePath, readFile} = await createTmpTranslationFile({ + key1: {message: 'key1 message'}, + }); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message new'}, + key2: {message: 'key2 message new'}, + }, + options: { + override: true, + messagePrefix: 'PREFIX ', + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'PREFIX key1 message new'}, + key2: {message: 'PREFIX key2 message new'}, + }); + }); + + test('should always override message description', async () => { + const {filePath, readFile} = await createTmpTranslationFile({ + key1: {message: 'key1 message', description: 'key1 desc'}, + key2: {message: 'key2 message', description: 'key2 desc'}, + key3: {message: 'key3 message', description: undefined}, + }); + + await writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message new', description: undefined}, + key2: {message: 'key2 message new', description: 'key2 desc new'}, + key3: {message: 'key3 message new', description: 'key3 desc new'}, + }, + }); + + expect(await readFile()).toEqual({ + key1: {message: 'key1 message', description: undefined}, + key2: {message: 'key2 message', description: 'key2 desc new'}, + key3: {message: 'key3 message', description: 'key3 desc new'}, + }); + }); + + test('should always override message description', async () => { + const {filePath} = await createTmpTranslationFile( + // @ts-expect-error: bad content on purpose + {bad: 'content'}, + ); + + await expect( + writeTranslationFileContent({ + filePath, + content: { + key1: {message: 'key1 message'}, + }, + }), + ).rejects.toThrowError(/Invalid translation file at path/); + }); +}); + +describe('writePluginTranslations', () => { + test('should write plugin translations', async () => { + const siteDir = await createTmpSiteDir(); + + const filePath = path.join( + siteDir, + 'i18n', + 'fr', + 'my-plugin-name', + 'my/translation/file.json', + ); + + await writePluginTranslations({ + siteDir, + locale: 'fr', + translationFile: { + path: 'my/translation/file', + content: { + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }, + }, + // @ts-expect-error: enough for this test + plugin: { + name: 'my-plugin-name', + options: { + id: undefined, + }, + }, + }); + + expect(await readTranslationFileContent(filePath)).toEqual({ + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }); + }); + + test('should write plugin translations consecutively with different options', async () => { + const siteDir = await createTmpSiteDir(); + + const filePath = path.join( + siteDir, + 'i18n', + 'fr', + 'my-plugin-name-my-plugin-id', + 'my/translation/file.json', + ); + + function doWritePluginTranslations( + content: TranslationFileContent, + options?: WriteTranslationsOptions, + ) { + return writePluginTranslations({ + siteDir, + locale: 'fr', + translationFile: { + path: 'my/translation/file', + content, + }, + // @ts-expect-error: enough for this test + plugin: { + name: 'my-plugin-name', + options: { + id: 'my-plugin-id', + }, + }, + options, + }); + } + + expect(await readTranslationFileContent(filePath)).toEqual(undefined); + + await doWritePluginTranslations({ + key1: {message: 'key1 message', description: 'key1 desc'}, + key2: {message: 'key2 message', description: 'key2 desc'}, + key3: {message: 'key3 message', description: 'key3 desc'}, + }); + expect(await readTranslationFileContent(filePath)).toEqual({ + key1: {message: 'key1 message', description: 'key1 desc'}, + key2: {message: 'key2 message', description: 'key2 desc'}, + key3: {message: 'key3 message', description: 'key3 desc'}, + }); + + await doWritePluginTranslations( + { + key3: {message: 'key3 message 2'}, + key4: {message: 'key4 message 2', description: 'key4 desc'}, + }, + {messagePrefix: 'PREFIX '}, + ); + expect(await readTranslationFileContent(filePath)).toEqual({ + key1: {message: 'key1 message', description: 'key1 desc'}, + key2: {message: 'key2 message', description: 'key2 desc'}, + key3: {message: 'key3 message', description: undefined}, + key4: {message: 'PREFIX key4 message 2', description: 'key4 desc'}, + }); + + await doWritePluginTranslations( + { + key1: {message: 'key1 message 3', description: 'key1 desc'}, + key2: {message: 'key2 message 3', description: 'key2 desc'}, + key3: {message: 'key3 message 3', description: 'key3 desc'}, + key4: {message: 'key4 message 3', description: 'key4 desc'}, + }, + {messagePrefix: 'PREFIX ', override: true}, + ); + expect(await readTranslationFileContent(filePath)).toEqual({ + key1: {message: 'PREFIX key1 message 3', description: 'key1 desc'}, + key2: {message: 'PREFIX key2 message 3', description: 'key2 desc'}, + key3: {message: 'PREFIX key3 message 3', description: 'key3 desc'}, + key4: {message: 'PREFIX key4 message 3', description: 'key4 desc'}, + }); + }); +}); + +describe('localizePluginTranslationFile', () => { + test('not localize if localized file does not exist', async () => { + const siteDir = await createTmpSiteDir(); + + const translationFile: TranslationFile = { + path: 'my/translation/file', + content: { + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }, + }; + + const localizedTranslationFile = await localizePluginTranslationFile({ + siteDir, + locale: 'fr', + translationFile, + // @ts-expect-error: enough for this test + plugin: { + name: 'my-plugin-name', + options: {}, + }, + }); + + expect(localizedTranslationFile).toEqual(translationFile); + }); + + test('not localize if localized file does not exist', async () => { + const siteDir = await createTmpSiteDir(); + + await writeTranslationFileContent({ + filePath: path.join( + siteDir, + 'i18n', + 'fr', + 'my-plugin-name', + 'my/translation/file.json', + ), + content: { + key2: {message: 'key2 message localized'}, + key4: {message: 'key4 message localized'}, + }, + }); + + const translationFile: TranslationFile = { + path: 'my/translation/file', + content: { + key1: {message: 'key1 message'}, + key2: {message: 'key2 message'}, + key3: {message: 'key3 message'}, + }, + }; + + const localizedTranslationFile = await localizePluginTranslationFile({ + siteDir, + locale: 'fr', + translationFile, + // @ts-expect-error: enough for this test + plugin: { + name: 'my-plugin-name', + options: {}, + }, + }); + + expect(localizedTranslationFile).toEqual({ + path: translationFile.path, + content: { + // We only append/override localized messages, but never delete the data of the unlocalized translation file + // This ensures that all required keys are present when trying to read the translations files + key1: {message: 'key1 message'}, + key2: {message: 'key2 message localized'}, + key3: {message: 'key3 message'}, + key4: {message: 'key4 message localized'}, + }, + }); + }); +}); diff --git a/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts new file mode 100644 index 000000000000..82e2d3eaf025 --- /dev/null +++ b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts @@ -0,0 +1,296 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs-extra'; +import tmp from 'tmp-promise'; +import { + extractSourceCodeFileTranslations, + extractPluginsSourceCodeTranslations, +} from '../translationsExtractor'; +import {getBabelOptions} from '../../../webpack/utils'; +import path from 'path'; +import {InitPlugin} from '../../plugins/init'; + +const TestBabelOptions = getBabelOptions({ + isServer: true, +}); + +async function createTmpDir() { + const {path: siteDirPath} = await tmp.dir({ + prefix: 'jest-createTmpSiteDir', + }); + return siteDirPath; +} + +async function createTmpSourceCodeFile({ + extension, + content, +}: { + extension: string; + content: string; +}) { + const file = await tmp.file({ + prefix: 'jest-createTmpSourceCodeFile', + postfix: `.${extension}`, + }); + + await fs.writeFile(file.path, content); + + return { + sourceCodeFilePath: file.path, + }; +} + +describe('extractSourceCodeTranslations', () => { + test('throw for bad source code', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +const default => { + +} +`, + }); + + await expect( + extractSourceCodeFileTranslations(sourceCodeFilePath, TestBabelOptions), + ).rejects.toThrowError( + /Error while attempting to extract Docusaurus translations from source code file at path/, + ); + }); + + test('extract nothing from untranslated source code', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +const unrelated = 42; +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: {}, + warnings: [], + }); + }); + + test('extract from a translate() function call', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +export default function MyComponent() { + return ( +
+ +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + codeId: {message: 'code message', description: 'code description'}, + }, + warnings: [], + }); + }); + + test('extract from a component', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +export default function MyComponent() { + return ( +
+ + code message + +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + codeId: {message: 'code message', description: 'code description'}, + }, + warnings: [], + }); + }); + + test('extract statically evaluable content', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'js', + content: ` +const prefix = "prefix "; + +export default function MyComponent() { + return ( +
+ + {prefix + "code message"} +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + 'prefix codeId comp': { + message: 'prefix code message', + description: 'prefix code description', + }, + 'prefix codeId fn': { + message: 'prefix code message', + description: 'prefix code description', + }, + }, + warnings: [], + }); + }); + + test('extract from TypeScript file', async () => { + const {sourceCodeFilePath} = await createTmpSourceCodeFile({ + extension: 'tsx', + content: ` +type ComponentProps = {toto: string} + +export default function MyComponent(props: ComponentProps) { + return ( +
+ +
+ ); +} +`, + }); + + const sourceCodeFileTranslations = await extractSourceCodeFileTranslations( + sourceCodeFilePath, + TestBabelOptions, + ); + + expect(sourceCodeFileTranslations).toEqual({ + sourceCodeFilePath, + translations: { + codeId: {message: 'code message', description: 'code description'}, + }, + warnings: [], + }); + }); +}); + +describe('extractPluginsSourceCodeTranslations', () => { + test('should extract translation from all plugins source code', async () => { + function createTestPlugin(pluginDir: string): InitPlugin { + // @ts-expect-error: good enough for this test + return { + name: 'abc', + getPathsToWatch() { + return [path.join(pluginDir, '**/*.{js,jsx,ts,tsx}')]; + }, + }; + } + + const plugin1Dir = await createTmpDir(); + const plugin1File = path.join(plugin1Dir, 'file.jsx'); + await fs.ensureDir(path.dirname(plugin1File)); + await fs.writeFile( + plugin1File, + ` +export default function MyComponent() { + return ( +
+ +
+ ); +} +`, + ); + const plugin1 = createTestPlugin(plugin1Dir); + + const plugin2Dir = await createTmpDir(); + const plugin2File = path.join(plugin1Dir, 'sub', 'path', 'file.tsx'); + await fs.ensureDir(path.dirname(plugin2File)); + await fs.writeFile( + plugin2File, + ` +type Props = {hey: string}; + +export default function MyComponent(props: Props) { + return ( +
+ + + plugin2 message 2 + +
+ ); +} +`, + ); + const plugin2 = createTestPlugin(plugin2Dir); + + const plugins = [plugin1, plugin2]; + const translations = await extractPluginsSourceCodeTranslations( + plugins, + TestBabelOptions, + ); + expect(translations).toEqual({ + plugin1Id: { + description: 'plugin1 description', + message: 'plugin1 message', + }, + plugin2Id: { + description: 'plugin2 description', + message: 'plugin2 message', + }, + plugin2Id2: { + description: 'plugin2 description 2', + message: 'plugin2 message 2', + }, + }); + }); +}); diff --git a/packages/docusaurus/src/server/translations/translations.ts b/packages/docusaurus/src/server/translations/translations.ts new file mode 100644 index 000000000000..9ff63f953708 --- /dev/null +++ b/packages/docusaurus/src/server/translations/translations.ts @@ -0,0 +1,259 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import path from 'path'; +import fs from 'fs-extra'; +import {InitPlugin} from '../plugins/init'; +import {mapValues, difference} from 'lodash'; +import {TranslationFileContent, TranslationFile} from '@docusaurus/types'; +import {getPluginI18nPath} from '@docusaurus/utils'; +import * as Joi from 'joi'; +import chalk from 'chalk'; + +export type WriteTranslationsOptions = { + override?: boolean; + messagePrefix?: string; +}; + +type TranslationContext = { + siteDir: string; + locale: string; +}; + +const TranslationFileContentSchema = Joi.object() + .pattern( + Joi.string(), + Joi.object({ + message: Joi.string().allow('').required(), + description: Joi.string().optional(), + }), + ) + .required(); + +export function ensureTranslationFileContent( + content: unknown, +): asserts content is TranslationFileContent { + Joi.attempt(content, TranslationFileContentSchema, { + abortEarly: false, + allowUnknown: false, + convert: false, + }); +} + +export async function readTranslationFileContent( + filePath: string, +): Promise { + if (await fs.pathExists(filePath)) { + try { + const content = JSON.parse(await fs.readFile(filePath, 'utf8')); + ensureTranslationFileContent(content); + return content; + } catch (e) { + throw new Error( + `Invalid translation file at path=${filePath}.\n${e.message}`, + ); + } + } + return undefined; +} + +function mergeTranslationFileContent({ + existingContent = {}, + newContent, + options, +}: { + existingContent: TranslationFileContent | undefined; + newContent: TranslationFileContent; + options: WriteTranslationsOptions; +}): TranslationFileContent { + // Apply messagePrefix to all messages + const newContentTransformed = mapValues(newContent, (value) => ({ + ...value, + message: `${options.messagePrefix ?? ''}${value.message}`, + })); + + const result: TranslationFileContent = {...existingContent}; + + // We only add missing keys here, we don't delete existing ones + Object.entries(newContentTransformed).forEach( + ([key, {message, description}]) => { + result[key] = { + // If the messages already exist, we don't override them (unless requested) + message: options.override + ? message + : existingContent[key]?.message ?? message, + description, // description + }; + }, + ); + + return result; +} + +export async function writeTranslationFileContent({ + filePath, + content: newContent, + options = {}, +}: { + filePath: string; + content: TranslationFileContent; + options?: WriteTranslationsOptions; +}): Promise { + const existingContent = await readTranslationFileContent(filePath); + + // Warn about potential legacy keys + const unknownKeys = difference( + Object.keys(existingContent ?? {}), + Object.keys(newContent), + ); + if (unknownKeys.length > 0) { + console.warn( + chalk.yellow(`Some translation keys looks unknown to us in file ${filePath} +Maybe you should remove them? +- ${unknownKeys.join('\n- ')}`), + ); + } + + const mergedContent = mergeTranslationFileContent({ + existingContent, + newContent, + options, + }); + + // Avoid creating empty translation files + if (Object.keys(mergedContent).length > 0) { + console.log( + `${Object.keys(mergedContent) + .length.toString() + .padStart(3, ' ')} translations written at ${path.relative( + process.cwd(), + filePath, + )}`, + ); + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, JSON.stringify(mergedContent, null, 2)); + } +} + +// should we make this configurable? +export function getTranslationsDirPath(context: TranslationContext): string { + return path.resolve(path.join(context.siteDir, `i18n`)); +} +export function getTranslationsLocaleDirPath( + context: TranslationContext, +): string { + return path.join(getTranslationsDirPath(context), context.locale); +} + +export function getCodeTranslationsFilePath( + context: TranslationContext, +): string { + return path.join(getTranslationsLocaleDirPath(context), 'code.json'); +} + +export async function readCodeTranslationFileContent( + context: TranslationContext, +): Promise { + return readTranslationFileContent(getCodeTranslationsFilePath(context)); +} +export async function writeCodeTranslations( + context: TranslationContext, + content: TranslationFileContent, + options: WriteTranslationsOptions, +): Promise { + return writeTranslationFileContent({ + filePath: getCodeTranslationsFilePath(context), + content, + options, + }); +} + +// We ask users to not provide any extension on purpose: +// maybe some day we'll want to support multiple FS formats? +// (json/yaml/toml/xml...) +function addTranslationFileExtension(translationFilePath: string) { + if (translationFilePath.endsWith('.json')) { + throw new Error( + `Translation file path does not need to end with .json, we addt the extension automatically. translationFilePath=${translationFilePath}`, + ); + } + return `${translationFilePath}.json`; +} + +function getPluginTranslationFilePath({ + siteDir, + plugin, + locale, + translationFilePath, +}: TranslationContext & { + plugin: InitPlugin; + translationFilePath: string; +}): string { + const dirPath = getPluginI18nPath({ + siteDir, + locale, + pluginName: plugin.name, + pluginId: plugin.options.id, + }); + const filePath = addTranslationFileExtension(translationFilePath); + return path.join(dirPath, filePath); +} + +export async function writePluginTranslations({ + siteDir, + plugin, + locale, + translationFile, + options, +}: TranslationContext & { + plugin: InitPlugin; + translationFile: TranslationFile; + options?: WriteTranslationsOptions; +}): Promise { + const filePath = getPluginTranslationFilePath({ + plugin, + siteDir, + locale, + translationFilePath: translationFile.path, + }); + await writeTranslationFileContent({ + filePath, + content: translationFile.content, + options, + }); +} + +export async function localizePluginTranslationFile({ + siteDir, + plugin, + locale, + translationFile, +}: TranslationContext & { + plugin: InitPlugin; + translationFile: TranslationFile; +}): Promise { + const filePath = getPluginTranslationFilePath({ + plugin, + siteDir, + locale, + translationFilePath: translationFile.path, + }); + + const localizedContent = await readTranslationFileContent(filePath); + + if (localizedContent) { + // localized messages "override" default unlocalized messages + return { + path: translationFile.path, + content: { + ...translationFile.content, + ...localizedContent, + }, + }; + } else { + return translationFile; + } +} diff --git a/packages/docusaurus/src/server/translations/translationsExtractor.ts b/packages/docusaurus/src/server/translations/translationsExtractor.ts new file mode 100644 index 000000000000..be0daf2dbeef --- /dev/null +++ b/packages/docusaurus/src/server/translations/translationsExtractor.ts @@ -0,0 +1,276 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import fs from 'fs-extra'; +import traverse, {Node} from '@babel/traverse'; +import generate from '@babel/generator'; +import chalk from 'chalk'; +import {parse, types as t, NodePath, TransformOptions} from '@babel/core'; +import {flatten} from 'lodash'; +import {TranslationFileContent, TranslationMessage} from '@docusaurus/types'; +import globby from 'globby'; +import nodePath from 'path'; +import {InitPlugin} from '../plugins/init'; + +// We only support extracting source code translations from these kind of files +const TranslatableSourceCodeExtension = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + // TODO support md/mdx too? (may be overkill) + // need to compile the MDX to JSX first and remove frontmatter + // '.md', + // '.mdx', +]); +function isTranslatableSourceCodePath(filePath: string): boolean { + return TranslatableSourceCodeExtension.has(nodePath.extname(filePath)); +} + +async function getSourceCodeFilePaths( + plugins: InitPlugin[], +): Promise { + // The getPathsToWatch() generally returns the js/jsx/ts/tsx/md/mdx file paths + // We can use this method as well to know which folders we should try to extract translations from + // Hacky/implicit, but do we want to introduce a new lifecycle method for that??? + const allPathsToWatch = flatten( + plugins.map((plugin) => plugin.getPathsToWatch?.() ?? []), + ); + + const filePaths = await globby(allPathsToWatch); + + return filePaths.filter(isTranslatableSourceCodePath); +} + +export async function extractPluginsSourceCodeTranslations( + plugins: InitPlugin[], + babelOptions: TransformOptions, +): Promise { + // Should we warn here if the same translation "key" is found in multiple source code files? + function toTranslationFileContent( + sourceCodeFileTranslations: SourceCodeFileTranslations[], + ): TranslationFileContent { + return sourceCodeFileTranslations.reduce((acc, item) => { + return {...acc, ...item.translations}; + }, {}); + } + + const sourceCodeFilePaths = await getSourceCodeFilePaths(plugins); + const sourceCodeFilesTranslations = await extractAllSourceCodeFileTranslations( + sourceCodeFilePaths, + babelOptions, + ); + + logSourceCodeFileTranslationsWarnings(sourceCodeFilesTranslations); + + return toTranslationFileContent(sourceCodeFilesTranslations); +} + +function logSourceCodeFileTranslationsWarnings( + sourceCodeFilesTranslations: SourceCodeFileTranslations[], +) { + sourceCodeFilesTranslations.forEach(({sourceCodeFilePath, warnings}) => { + if (warnings.length > 0) { + console.warn( + `Translation extraction warnings for file path=${sourceCodeFilePath}:\n- ${chalk.yellow( + warnings.join('\n\n- '), + )}`, + ); + } + }); +} + +type SourceCodeFileTranslations = { + sourceCodeFilePath: string; + translations: Record; + warnings: string[]; +}; + +async function extractAllSourceCodeFileTranslations( + sourceCodeFilePaths: string[], + babelOptions: TransformOptions, +): Promise { + return flatten( + await Promise.all( + sourceCodeFilePaths.map((sourceFilePath) => + extractSourceCodeFileTranslations(sourceFilePath, babelOptions), + ), + ), + ); +} + +export async function extractSourceCodeFileTranslations( + sourceCodeFilePath: string, + babelOptions: TransformOptions, +): Promise { + try { + const code = await fs.readFile(sourceCodeFilePath, 'utf8'); + + const ast = parse(code, { + ...babelOptions, + ast: true, + // filename is important, because babel does not process the same files according to their js/ts extensions + // see see https://twitter.com/NicoloRibaudo/status/1321130735605002243 + filename: sourceCodeFilePath, + }) as Node; + + return await extractSourceCodeAstTranslations(ast, sourceCodeFilePath); + } catch (e) { + e.message = `Error while attempting to extract Docusaurus translations from source code file at path=${sourceCodeFilePath}\n${e.message}`; + throw e; + } +} + +/* +Need help understanding this? + +Useful resources: +https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md +https://github.com/formatjs/formatjs/blob/main/packages/babel-plugin-react-intl/index.ts +https://github.com/pugjs/babel-walk + */ +function extractSourceCodeAstTranslations( + ast: Node, + sourceCodeFilePath: string, +): SourceCodeFileTranslations { + function staticTranslateJSXWarningPart() { + return 'Translate content could not be extracted.\nIt has to be a static string, like text.'; + } + function sourceFileWarningPart(node: Node) { + return `File=${sourceCodeFilePath} at line=${node.loc?.start.line}`; + } + function generateCode(node: Node) { + return generate(node as any).code; + } + + const translations: Record = {}; + const warnings: string[] = []; + + // TODO we should check the presence of the correct @docusaurus imports here! + + traverse(ast, { + JSXElement(path) { + function evaluateJSXProp(propName: string): string | undefined { + const attributePath = path + .get('openingElement.attributes') + .find( + (attr) => attr.isJSXAttribute() && attr.node.name.name === propName, + ); + + if (attributePath) { + const attributeValue = attributePath.get('value') as NodePath; + + const attributeValueEvaluated = + attributeValue.node.type === 'JSXExpressionContainer' + ? (attributeValue.get('expression') as NodePath).evaluate() + : attributeValue.evaluate(); + + if ( + attributeValueEvaluated.confident && + typeof attributeValueEvaluated.value === 'string' + ) { + return attributeValueEvaluated.value; + } else { + warnings.push( + ` prop=${propName} should be a statically evaluable object.\nExample: Message\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceFileWarningPart( + path.node, + )}\n${generateCode(path.node)}`, + ); + } + } + + return undefined; + } + + if ( + path.node.openingElement.name.type === 'JSXIdentifier' && + path.node.openingElement.name.name === 'Translate' + ) { + // TODO support multiple childrens here? + if ( + path.node.children.length === 1 && + t.isJSXText(path.node.children[0]) + ) { + const message = path.node.children[0].value + .trim() + .replace(/\s+/g, ' '); + + const id = evaluateJSXProp('id'); + const description = evaluateJSXProp('description'); + + translations[id ?? message] = { + message, + ...(description && {description}), + }; + } else if ( + path.node.children.length === 1 && + t.isJSXExpressionContainer(path.node.children[0]) && + (path.get('children.0.expression') as NodePath).evaluate().confident + ) { + const message = (path.get( + 'children.0.expression', + ) as NodePath).evaluate().value; + + const id = evaluateJSXProp('id'); + const description = evaluateJSXProp('description'); + + translations[id ?? message] = { + message, + ...(description && {description}), + }; + } else { + warnings.push( + `${staticTranslateJSXWarningPart}\n${sourceFileWarningPart( + path.node, + )}\n${generateCode(path.node)}`, + ); + } + } + }, + + CallExpression(path) { + if ( + path.node.callee.type === 'Identifier' && + path.node.callee.name === 'translate' + ) { + // console.log('CallExpression', path.node); + if (path.node.arguments.length === 1) { + const firstArgPath = path.get('arguments.0') as NodePath; + + // evaluation allows translate("x" + "y"); to be considered as translate("xy"); + const firstArgEvaluated = firstArgPath.evaluate(); + + // console.log('firstArgEvaluated', firstArgEvaluated); + + if ( + firstArgEvaluated.confident && + typeof firstArgEvaluated.value === 'object' + ) { + const {message, id, description} = firstArgEvaluated.value; + translations[id ?? message] = { + message, + ...(description && {description}), + }; + } else { + warnings.push( + `translate() first arg should be a statically evaluable object.\nExample: translate({message: "text",id: "optional.id",description: "optional description"}\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceFileWarningPart( + path.node, + )}\n${generateCode(path.node)}`, + ); + } + } else { + warnings.push( + `translate() function only takes 1 arg\n${sourceFileWarningPart( + path.node, + )}\n${generateCode(path.node)}`, + ); + } + } + }, + }); + + return {sourceCodeFilePath, translations, warnings}; +} diff --git a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap index f17a59672e49..a9a3d4752149 100644 --- a/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap +++ b/packages/docusaurus/src/webpack/__tests__/__snapshots__/base.test.ts.snap @@ -8,6 +8,7 @@ Object { "@docusaurus/Head": "../../client/exports/Head.tsx", "@docusaurus/Link": "../../client/exports/Link.tsx", "@docusaurus/Noop": "../../client/exports/Noop.ts", + "@docusaurus/Translate": "../../client/exports/Translate.tsx", "@docusaurus/constants": "../../client/exports/constants.ts", "@docusaurus/context": "../../client/exports/context.ts", "@docusaurus/isInternalUrl": "../../client/exports/isInternalUrl.ts", diff --git a/packages/docusaurus/src/webpack/base.ts b/packages/docusaurus/src/webpack/base.ts index bc18a5e0eef9..fff5da0a1748 100644 --- a/packages/docusaurus/src/webpack/base.ts +++ b/packages/docusaurus/src/webpack/base.ts @@ -16,9 +16,9 @@ import { getCacheLoader, getStyleLoaders, getFileLoaderUtils, + getCustomBabelConfigFilePath, getMinimizer, } from './utils'; -import {BABEL_CONFIG_FILE_NAME} from '../constants'; const CSS_REGEX = /\.css$/; const CSS_MODULE_REGEX = /\.module\.css$/; @@ -68,11 +68,6 @@ export function createBaseConfig( const minimizeEnabled = minify && isProd && !isServer; const useSimpleCssMinifier = process.env.USE_SIMPLE_CSS_MINIFIER === 'true'; - const customBabelConfigurationPath = path.join( - siteDir, - BABEL_CONFIG_FILE_NAME, - ); - const fileLoaderUtils = getFileLoaderUtils(); return { @@ -162,12 +157,7 @@ export function createBaseConfig( exclude: excludeJS, use: [ getCacheLoader(isServer), - getBabelLoader( - isServer, - fs.existsSync(customBabelConfigurationPath) - ? customBabelConfigurationPath - : undefined, - ), + getBabelLoader(isServer, getCustomBabelConfigFilePath(siteDir)), ].filter(Boolean) as Loader[], }, { diff --git a/packages/docusaurus/src/webpack/utils.ts b/packages/docusaurus/src/webpack/utils.ts index 12455679e0a7..e079671e914e 100644 --- a/packages/docusaurus/src/webpack/utils.ts +++ b/packages/docusaurus/src/webpack/utils.ts @@ -9,10 +9,10 @@ import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import env from 'std-env'; import merge from 'webpack-merge'; import webpack, {Configuration, Loader, RuleSetRule, Stats} from 'webpack'; +import fs from 'fs-extra'; import TerserPlugin from 'terser-webpack-plugin'; import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin'; import CleanCss from 'clean-css'; -import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import chalk from 'chalk'; @@ -20,7 +20,7 @@ import {TransformOptions} from '@babel/core'; import {ConfigureWebpackFn} from '@docusaurus/types'; import CssNanoPreset from '@docusaurus/cssnano-preset'; import {version as cacheLoaderVersion} from 'cache-loader/package.json'; -import {STATIC_ASSETS_DIR_NAME} from '../constants'; +import {BABEL_CONFIG_FILE_NAME, STATIC_ASSETS_DIR_NAME} from '../constants'; // Utility method to get style loaders export function getStyleLoaders( @@ -93,19 +93,33 @@ export function getCacheLoader( }; } -export function getBabelLoader( - isServer: boolean, - babelOptions?: TransformOptions | string, -): Loader { - let options: TransformOptions; +export function getCustomBabelConfigFilePath( + siteDir: string, +): string | undefined { + const customBabelConfigurationPath = path.join( + siteDir, + BABEL_CONFIG_FILE_NAME, + ); + return fs.existsSync(customBabelConfigurationPath) + ? customBabelConfigurationPath + : undefined; +} + +export function getBabelOptions({ + isServer, + babelOptions, +}: { + isServer?: boolean; + babelOptions?: TransformOptions | string; +} = {}): TransformOptions { if (typeof babelOptions === 'string') { - options = { + return { babelrc: false, configFile: babelOptions, caller: {name: isServer ? 'server' : 'client'}, }; } else { - options = Object.assign( + return Object.assign( babelOptions ?? {presets: [require.resolve('../babel/preset')]}, { babelrc: false, @@ -114,9 +128,15 @@ export function getBabelLoader( }, ); } +} + +export function getBabelLoader( + isServer: boolean, + babelOptions?: TransformOptions | string, +): Loader { return { loader: require.resolve('babel-loader'), - options, + options: getBabelOptions({isServer, babelOptions}), }; } diff --git a/tsconfig.json b/tsconfig.json index d3960479f1e6..c6c2da9e45b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "alwaysStrict": true, /* Additional Checks */ - "noUnusedLocals": true, + "noUnusedLocals": false, // ensured by eslint, should not block compilation "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 442cdfe24f3b..9057382e932c 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -49,8 +49,12 @@ module.exports = { baseUrl, baseUrlIssueBanner: true, url: 'https://v2.docusaurus.io', - onBrokenLinks: isVersioningDisabled ? 'warn' : 'throw', - onBrokenMarkdownLinks: 'warn', + i18n: { + defaultLocale: 'en', + locales: ['en', 'fr'], + }, + onBrokenLinks: 'throw', + onBrokenMarkdownLinks: 'throw', favicon: 'img/docusaurus.ico', customFields: { description: diff --git a/website/package.json b/website/package.json index 7d5996572634..4e1fdb43ada9 100644 --- a/website/package.json +++ b/website/package.json @@ -16,12 +16,13 @@ "build:bootstrap": "cross-env DOCUSAURUS_PRESET=bootstrap yarn build", "start:blogOnly": "cross-env DOCUSAURUS_CONFIG='docusaurus.config-blog-only.js' yarn start", "build:blogOnly": "cross-env DOCUSAURUS_CONFIG='docusaurus.config-blog-only.js' yarn build", - "netlify:build:production": "yarn build", - "netlify:build:deployPreview": "yarn netlify:build:deployPreview:v1:all && yarn netlify:build:deployPreview:classic && yarn netlify:build:deployPreview:bootstrap && yarn netlify:build:deployPreview:blogOnly", + "netlify:build:production": "yarn netlify:crowdin && yarn build", + "netlify:build:deployPreview": "yarn docusaurus write-translations --locale fr --messagePrefix '(fr) ' && yarn netlify:crowdin && yarn netlify:build:deployPreview:v1:all && yarn netlify:build:deployPreview:classic && yarn netlify:build:deployPreview:bootstrap && yarn netlify:build:deployPreview:blogOnly", "netlify:build:deployPreview:classic": "cross-env BASE_URL='/classic/' yarn build --out-dir netlifyDeployPreview/classic", "netlify:build:deployPreview:bootstrap": "cross-env BASE_URL='/bootstrap/' DOCUSAURUS_PRESET=bootstrap DISABLE_VERSIONING=true yarn build --out-dir netlifyDeployPreview/bootstrap", "netlify:build:deployPreview:blogOnly": "yarn build:blogOnly --out-dir netlifyDeployPreview/blog-only", "netlify:build:deployPreview:v1:all": "yarn --cwd .. netlify:deployPreview:v1 && yarn --cwd .. netlify:deployPreview:v1-migrated", + "netlify:crowdin": "cd .. && java -version && curl https://hardcore-ride-8fbb5a.netlify.app/crowdin-cli.jar --output crowdin-cli.jar && java -jar crowdin-cli.jar --version && java -jar crowdin-cli.jar download --config ./crowdin-v2.yaml", "netlify:test": "yarn netlify:build:deployPreview && yarn netlify dev --debug" }, "dependencies": { diff --git a/website/src/pages/examples/markdownPageExample.md b/website/src/pages/examples/markdownPageExample.md index c9350bd89342..1c9e1722de90 100644 --- a/website/src/pages/examples/markdownPageExample.md +++ b/website/src/pages/examples/markdownPageExample.md @@ -26,10 +26,6 @@ function Button() { } ``` -### Using relative path - -![](../../../static/img/docusaurus.png) - ### Using absolute path ![](/img/docusaurus.png) @@ -84,7 +80,7 @@ My comment ## Import code block from source code file -import MyComponent from "./\_myComponent" +import MyComponent from "@site/src/pages/examples/\_myComponent" import BrowserWindow from '@site/src/components/BrowserWindow'; @@ -116,7 +112,7 @@ import MyComponentSource from '!!raw-loader!./myComponent'; import CodeBlock from "@theme/CodeBlock" -import MyComponentSource from '!!raw-loader!./\_myComponent'; +import MyComponentSource from '!!raw-loader!@site/src/pages/examples/\_myComponent'; diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 960fc23b8b5b..caff57016732 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -7,6 +7,7 @@ import React from 'react'; import Link from '@docusaurus/Link'; +import Translate, {translate} from '@docusaurus/Translate'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useBaseUrl from '@docusaurus/useBaseUrl'; @@ -21,40 +22,58 @@ const QUOTES = [ { thumbnail: require('../data/quotes/christopher-chedeau.jpg'), name: 'Christopher "vjeux" Chedeau', - title: 'Lead Prettier Developer', + title: translate({ + id: 'homepage.quotes.christopher-chedeau.title', + message: 'Lead Prettier Developer', + description: 'Title of quote of Christopher Chedeau on the home page', + }), text: ( - <> + I've helped open source many projects at Facebook and every one needed a website. They all had very similar constraints: the documentation should be written in markdown and be deployed via GitHub pages. I’m so glad that Docusaurus now exists so that I don’t have to spend a week each time spinning up a new one. - + ), }, { thumbnail: require('../data/quotes/hector-ramos.jpg'), name: 'Hector Ramos', - title: 'Lead React Native Advocate', + title: translate({ + id: 'homepage.quotes.hector-ramos.title', + message: 'Lead React Native Advocate', + description: 'Title of quote of Hector Ramos on the home page', + }), text: ( - <> + Open source contributions to the React Native docs have skyrocketed after our move to Docusaurus. The docs are now hosted on a small repo in plain markdown, with none of the clutter that a typical static site generator would require. Thanks Slash! - + ), }, { thumbnail: require('../data/quotes/ricky-vetter.jpg'), name: 'Ricky Vetter', - title: 'ReasonReact Developer', + title: translate({ + id: 'homepage.quotes.risky-vetter.title', + message: 'ReasonReact Developer', + description: 'Title of quote of Ricky Vetter on the home page', + }), text: ( - <> + Docusaurus has been a great choice for the ReasonML family of projects. It makes our documentation consistent, i18n-friendly, easy to maintain, and friendly for new contributors. - + ), }, ]; @@ -72,22 +91,28 @@ function Home() {

Docusaurus with Keytar - Build{' '} - optimized{' '} - websites{' '} - quickly, focus - on your{' '} - content + optimized websites quickly, focus on your content', + description: + 'Home page hero title, can contain simple html tags', + }), + }} + />

- Get Started + Get Started