test: improve test coverage; multiple internal refactors (#6912)

This commit is contained in:
Joshua Chen 2022-03-14 21:53:57 +08:00 committed by GitHub
parent 12a7305238
commit ad88f5cc87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 1613 additions and 1149 deletions

View file

@ -37,10 +37,7 @@ async function findPackageManagerFromLockFile(): Promise<
SupportedPackageManager | undefined
> {
for (const packageManager of PackageManagersList) {
const lockFilePath = path.resolve(
process.cwd(),
SupportedPackageManagers[packageManager],
);
const lockFilePath = path.resolve(SupportedPackageManagers[packageManager]);
if (await fs.pathExists(lockFilePath)) {
return packageManager;
}
@ -152,7 +149,7 @@ async function copyTemplate(
template: string,
dest: string,
) {
await fs.copy(path.resolve(templatesDir, 'shared'), dest);
await fs.copy(path.join(templatesDir, 'shared'), dest);
// TypeScript variants will copy duplicate resources like CSS & config from
// base template
@ -211,7 +208,7 @@ export default async function init(
const templates = await readTemplates(templatesDir);
const hasTS = (templateName: string) =>
fs.pathExists(
path.resolve(templatesDir, `${templateName}${TypeScriptTemplateSuffix}`),
path.join(templatesDir, `${templateName}${TypeScriptTemplateSuffix}`),
);
let name = siteName;
@ -297,7 +294,7 @@ export default async function init(
name: 'templateDir',
validate: async (dir?: string) => {
if (dir) {
const fullDir = path.resolve(process.cwd(), dir);
const fullDir = path.resolve(dir);
if (await fs.pathExists(fullDir)) {
return true;
}
@ -351,8 +348,8 @@ export default async function init(
logger.error`Copying Docusaurus template name=${template} failed!`;
throw err;
}
} else if (await fs.pathExists(path.resolve(process.cwd(), template))) {
const templateDir = path.resolve(process.cwd(), template);
} else if (await fs.pathExists(path.resolve(template))) {
const templateDir = path.resolve(template);
try {
await fs.copy(templateDir, dest);
} catch (err) {

View file

@ -0,0 +1 @@
[link](../docs/doc1.md)

View file

@ -193,4 +193,14 @@ describe('linkify', () => {
expect(transformedContent).not.toContain('](../doc2.md)');
expect(content).not.toEqual(transformedContent);
});
// See comment in linkify.ts
it('throws for file outside version', async () => {
const doc1 = path.join(__dirname, '__fixtures__/outside/doc1.md');
await expect(() =>
transform(doc1),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unexpected error: Markdown file at \\"<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/outside/doc1.md\\" does not belong to any docs version!"`,
);
});
});

View file

@ -15,6 +15,10 @@ function getVersion(filePath: string, options: DocsMarkdownOption) {
filePath.startsWith(docsDirPath),
),
);
// At this point, this should never happen, because the MDX loaders' paths are
// literally using the version content paths; but if we allow sourcing content
// from outside the docs directory (through the `include` option, for example;
// is there a compelling use-case?), this would actually be testable
if (!versionFound) {
throw new Error(
`Unexpected error: Markdown file at "${filePath}" does not belong to any docs version!`,

View file

@ -58,22 +58,17 @@ function postProcessSidebarItem(
`Sidebar category ${item.label} has neither any subitem nor a link. This makes this item not able to link to anything.`,
);
}
switch (category.link.type) {
case 'doc':
return {
return category.link.type === 'doc'
? {
type: 'doc',
label: category.label,
id: category.link.id,
};
case 'generated-index':
return {
}
: {
type: 'link',
label: category.label,
href: category.link.permalink,
};
default:
throw new Error('Unexpected sidebar category link type');
}
}
// A non-collapsible category can't be collapsed!
if (category.collapsible === false) {

View file

@ -376,18 +376,13 @@ export function toNavigationLink(
return undefined;
}
if (navigationItem.type === 'doc') {
return toDocNavigationLink(getDocById(navigationItem.id));
} else if (navigationItem.type === 'category') {
if (navigationItem.link.type === 'doc') {
return toDocNavigationLink(getDocById(navigationItem.link.id));
} else if (navigationItem.link.type === 'generated-index') {
return {
if (navigationItem.type === 'category') {
return navigationItem.link.type === 'doc'
? toDocNavigationLink(getDocById(navigationItem.link.id))
: {
title: navigationItem.label,
permalink: navigationItem.link.permalink,
};
}
throw new Error('unexpected category link type');
}
throw new Error('unexpected navigation item');
return toDocNavigationLink(getDocById(navigationItem.id));
}

View file

@ -6,7 +6,7 @@
*/
import {jest} from '@jest/globals';
import {extractThemeCodeMessages} from '../update';
import {extractThemeCodeMessages} from '../../src/utils';
import path from 'path';
import fs from 'fs-extra';
import _ from 'lodash';
@ -16,20 +16,17 @@ jest.setTimeout(15000);
describe('theme translations', () => {
it('has base messages files contain EXACTLY all the translations extracted from the theme. Please run "yarn workspace @docusaurus/theme-translations update" to keep base messages files up-to-date', async () => {
const baseMessagesDirPath = path.join(__dirname, '../locales/base');
const baseMessagesDirPath = path.join(__dirname, '../base');
const baseMessages = Object.fromEntries(
await Promise.all(
(
await fs.readdir(baseMessagesDirPath)
).map(async (baseMessagesFile) =>
Object.entries(
JSON.parse(
(
await fs.readFile(
(await fs.readJSON(
path.join(baseMessagesDirPath, baseMessagesFile),
)
).toString(),
) as Record<string, string>,
'utf-8',
)) as Record<string, string>,
),
),
).then((translations) =>

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "أحدث مشاركات المدونة",
"theme.blog.tagTitle": "{nPosts} موسومة ب \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "تعديل هذه الصفحة",
"theme.common.headingLinkTitle": "ارتباط مباشر بالعنوان",
"theme.common.skipToMainContent": "انتقل إلى المحتوى الرئيسي",

View file

@ -53,10 +53,10 @@
"theme.blog.tagTitle___DESCRIPTION": "The title of the page for a blog tag",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel___DESCRIPTION": "The ARIA label for the navbar color mode toggle",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.light___DESCRIPTION": "The name for the light color mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.dark___DESCRIPTION": "The name for the dark color mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.light___DESCRIPTION": "The name for the light color mode",
"theme.common.editThisPage": "Edit this page",
"theme.common.editThisPage___DESCRIPTION": "The link label to edit the current page",
"theme.common.headingLinkTitle": "Direct link to heading",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "সাম্প্রতিক ব্লগ পোস্ট নেভিগেশন",
"theme.blog.tagTitle": "{nPosts} সঙ্গে ট্যাগ্গেড \"{tagName}\" ",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "এই পেজটি এডিট করুন",
"theme.common.headingLinkTitle": "হেডিং এর সঙ্গে সরাসরি লিংকড",
"theme.common.skipToMainContent": "স্কিপ করে মূল কন্টেন্ট এ যান",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Navigace s aktuálními články na blogu",
"theme.blog.tagTitle": "{nPosts} s tagem \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Upravit tuto stránku",
"theme.common.headingLinkTitle": "Přímý odkaz na nadpis",
"theme.common.skipToMainContent": "Přeskočit na hlavní obsah",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} med følgende tag \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Rediger denne side",
"theme.common.headingLinkTitle": "Direkte link til overskrift",
"theme.common.skipToMainContent": "Hop til hovedindhold",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} getaggt mit \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Diese Seite bearbeiten",
"theme.common.headingLinkTitle": "Direkter Link zur Überschrift",
"theme.common.skipToMainContent": "Zum Hauptinhalt springen",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Navegación de publicaciones recientes",
"theme.blog.tagTitle": "{nPosts} etiquetados con \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Editar esta página",
"theme.common.headingLinkTitle": "Enlace directo al encabezado",
"theme.common.skipToMainContent": "Saltar al contenido principal",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "کنترل پست های اخیر وبلاگ",
"theme.blog.tagTitle": "{nPosts} با برچسب \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "ویرایش مطالب این صفحه",
"theme.common.headingLinkTitle": "لینک مستقیم به عنوان",
"theme.common.skipToMainContent": "پرش به مطلب اصلی",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} na may tag na \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "I-edit ang page",
"theme.common.headingLinkTitle": "Direktang link patungo sa heading",
"theme.common.skipToMainContent": "Lumaktaw patungo sa pangunahing content",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Navigation article de blog récent",
"theme.blog.tagTitle": "{nPosts} tagués avec « {tagName} »",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Éditer cette page",
"theme.common.headingLinkTitle": "Lien direct vers le titre",
"theme.common.skipToMainContent": "Aller au contenu principal",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "מעבר לרשומות אחרונות בבלוג",
"theme.blog.tagTitle": "{nPosts} עם התגית \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "ערוך דף זה",
"theme.common.headingLinkTitle": "קישור ישיר לכותרת",
"theme.common.skipToMainContent": "דלג לתוכן הראשי",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "नया ब्लॉग पोस्ट नेविगेशन",
"theme.blog.tagTitle": "{nPosts} पोस्ट \"{tagName}\" टैग के साथ",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "इस पेज को बदलें",
"theme.common.headingLinkTitle": "शीर्षक का सीधा लिंक",
"theme.common.skipToMainContent": "मुख्य कंटेंट तक स्किप करें",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Navigazione dei post recenti del blog",
"theme.blog.tagTitle": "{nPosts} etichettati con \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Modifica questa pagina",
"theme.common.headingLinkTitle": "Link diretto all'intestazione",
"theme.common.skipToMainContent": "Passa al contenuto principale",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "「{tagName}」タグの記事が{nPosts}あります",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "このページを編集",
"theme.common.headingLinkTitle": "見出しへの直接リンク",
"theme.common.skipToMainContent": "メインコンテンツまでスキップ",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "최근 블로그 문서 둘러보기",
"theme.blog.tagTitle": "\"{tagName}\" 태그로 연결된 {nPosts}개의 게시물이 있습니다.",
"theme.colorToggle.ariaLabel": "어두운 모드와 밝은 모드 전환하기 (현재 {mode})",
"theme.colorToggle.ariaLabel.mode.light": "밝은 모드",
"theme.colorToggle.ariaLabel.mode.dark": "어두운 모드",
"theme.colorToggle.ariaLabel.mode.light": "밝은 모드",
"theme.common.editThisPage": "페이지 편집",
"theme.common.headingLinkTitle": "제목으로 바로 가기",
"theme.common.skipToMainContent": "본문으로 건너뛰기",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} z tagiem \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Edytuj tą stronę",
"theme.common.headingLinkTitle": "Bezpośredni link do nagłówka",
"theme.common.skipToMainContent": "Przejdź do głównej zawartości",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} marcadas com \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Editar essa página",
"theme.common.headingLinkTitle": "Link direto para o título",
"theme.common.skipToMainContent": "Pular para o conteúdo principal",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog recent posts navigation",
"theme.blog.tagTitle": "{nPosts} marcadas com \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Editar esta página",
"theme.common.headingLinkTitle": "Link direto para o título",
"theme.common.skipToMainContent": "Saltar para o conteúdo principal",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Навигация по последним постам в блоге",
"theme.blog.tagTitle": "{nPosts} с тегом \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Переключение между темным и светлым режимом (сейчас используется {mode})",
"theme.colorToggle.ariaLabel.mode.light": "Светлый режим",
"theme.colorToggle.ariaLabel.mode.dark": "Тёмный режим",
"theme.colorToggle.ariaLabel.mode.light": "Светлый режим",
"theme.common.editThisPage": "Отредактировать эту страницу",
"theme.common.headingLinkTitle": "Прямая ссылка на этот заголовок",
"theme.common.skipToMainContent": "Перейти к основному содержимому",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Недавни постови на блогу",
"theme.blog.tagTitle": "{nPosts} означени са \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Уреди ову страницу",
"theme.common.headingLinkTitle": "Веза до наслова",
"theme.common.skipToMainContent": "Пређи на главни садржај",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Blog son gönderiler navigasyonu",
"theme.blog.tagTitle": "\"{tagName}\" ile etiketlenmiş {nPosts}",
"theme.colorToggle.ariaLabel": "Switch between dark and light mode (currently {mode})",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.colorToggle.ariaLabel.mode.dark": "dark mode",
"theme.colorToggle.ariaLabel.mode.light": "light mode",
"theme.common.editThisPage": "Bu sayfayı düzenle",
"theme.common.headingLinkTitle": "Başlığa doğrudan bağlantı",
"theme.common.skipToMainContent": "Ana içeriğe geç",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "Điều hướng các bài viết gần đây trên blog",
"theme.blog.tagTitle": "{nPosts} được gắn thẻ \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Chuyển đổi chế độ sáng và tối (hiện tại {mode})",
"theme.colorToggle.ariaLabel.mode.light": "chế độ sáng",
"theme.colorToggle.ariaLabel.mode.dark": "chế độ tối",
"theme.colorToggle.ariaLabel.mode.light": "chế độ sáng",
"theme.common.editThisPage": "Sửa trang này",
"theme.common.headingLinkTitle": "Đường dẫn trực tiếp tới đề mục này",
"theme.common.skipToMainContent": "Nhảy tới nội dung",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "最近博文导航",
"theme.blog.tagTitle": "{nPosts} 含有标签「{tagName}」",
"theme.colorToggle.ariaLabel": "切换浅色/暗黑模式(当前为{mode}",
"theme.colorToggle.ariaLabel.mode.light": "浅色模式",
"theme.colorToggle.ariaLabel.mode.dark": "暗黑模式",
"theme.colorToggle.ariaLabel.mode.light": "浅色模式",
"theme.common.editThisPage": "编辑此页",
"theme.common.headingLinkTitle": "标题的直接链接",
"theme.common.skipToMainContent": "跳到主要内容",

View file

@ -26,8 +26,8 @@
"theme.blog.sidebar.navAriaLabel": "最近部落格文章導覽",
"theme.blog.tagTitle": "{nPosts} 含有標籤「{tagName}」",
"theme.colorToggle.ariaLabel": "切換淺色/暗黑模式(當前為{mode}",
"theme.colorToggle.ariaLabel.mode.light": "淺色模式",
"theme.colorToggle.ariaLabel.mode.dark": "暗黑模式",
"theme.colorToggle.ariaLabel.mode.light": "淺色模式",
"theme.common.editThisPage": "編輯此頁",
"theme.common.headingLinkTitle": "標題的直接連結",
"theme.common.skipToMainContent": "跳至主要内容",

View file

@ -14,9 +14,9 @@
},
"license": "MIT",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"update": "node ./update.js"
"build": "tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch",
"update": "node ./update.mjs"
},
"dependencies": {
"fs-extra": "^10.0.1",

View file

@ -0,0 +1,5 @@
import Translate from '@docusaurus/Translate';
export default function Foo() {
return <Translate>{index}</Translate>;
}

View file

@ -0,0 +1,26 @@
/**
* 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 {extractThemeCodeMessages} from '../utils';
describe('extractThemeCodeMessages', () => {
it('throws with invalid syntax', async () => {
await expect(() =>
extractThemeCodeMessages([path.join(__dirname, '__fixtures__/theme')]),
).rejects.toThrowErrorMatchingInlineSnapshot(`
"
Please make sure all theme translations are static!
Some warnings were found!
Translate content could not be extracted. It has to be a static string and use optional but static props, like <Translate id=\\"my-id\\" description=\\"my-description\\">text</Translate>.
File: packages/docusaurus-theme-translations/src/__tests__/__fixtures__/theme/index.js at line 4
Full code: <Translate>{index}</Translate>
"
`);
});
});

View file

@ -0,0 +1,100 @@
/**
* 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.
*/
// This file isn't used by index.ts. It's used by update.mjs and tests. It's
// only here so that (a) we get a partially typed infrastructure (although the
// update script has ts-check anyways) (b) the test coverage isn't destroyed by
// the untested update.mjs file (c) we can ergonomically import the util
// functions in the Jest test without using `await import`
/* eslint-disable import/no-extraneous-dependencies */
import path from 'path';
import fs from 'fs-extra';
// Unsafe import, should we create a package for the translationsExtractor ?;
import {
globSourceCodeFilePaths,
extractAllSourceCodeFileTranslations,
} from '@docusaurus/core/lib/server/translations/translationsExtractor';
import type {TranslationFileContent} from '@docusaurus/types';
async function getPackageCodePath(packageName: string) {
const packagePath = path.join(__dirname, '../..', packageName);
const packageJsonPath = path.join(packagePath, 'package.json');
const {main} = await fs.readJSON(packageJsonPath);
const packageSrcPath = path.join(packagePath, path.dirname(main));
const packageLibNextPath = packageSrcPath.replace('lib', 'lib-next');
return (await fs.pathExists(packageLibNextPath))
? packageLibNextPath
: packageSrcPath;
}
export async function getThemes(): Promise<{name: string; src: string[]}[]> {
return [
{
name: 'theme-common',
src: [
await getPackageCodePath('docusaurus-theme-classic'),
await getPackageCodePath('docusaurus-theme-common'),
],
},
{
name: 'theme-search-algolia',
src: [await getPackageCodePath('docusaurus-theme-search-algolia')],
},
{
name: 'theme-live-codeblock',
src: [await getPackageCodePath('docusaurus-theme-live-codeblock')],
},
{
name: 'plugin-pwa',
src: [await getPackageCodePath('docusaurus-plugin-pwa')],
},
{
name: 'plugin-ideal-image',
src: [await getPackageCodePath('docusaurus-plugin-ideal-image')],
},
];
}
export async function extractThemeCodeMessages(
targetDirs?: string[],
): Promise<TranslationFileContent> {
// eslint-disable-next-line no-param-reassign
targetDirs ??= (await getThemes()).flatMap((theme) => theme.src);
const filePaths = (await globSourceCodeFilePaths(targetDirs)).filter(
(filePath) => ['.js', '.jsx'].includes(path.extname(filePath)),
);
const filesExtractedTranslations = await extractAllSourceCodeFileTranslations(
filePaths,
{
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
},
);
filesExtractedTranslations.forEach((fileExtractedTranslations) => {
if (fileExtractedTranslations.warnings.length > 0) {
throw new Error(`
Please make sure all theme translations are static!
Some warnings were found!
${fileExtractedTranslations.warnings.join('\n\n')}
`);
}
});
const translations = filesExtractedTranslations.reduce(
(acc, extractedTranslations) => ({
...acc,
...extractedTranslations.translations,
}),
{},
);
return translations;
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"sourceMap": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "lib"
}
}

View file

@ -1,11 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"sourceMap": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "lib"
}
"module": "esnext",
"noEmit": true,
"checkJs": true,
"allowJs": true
},
"include": ["update.mjs", "src"]
}

View file

@ -1,12 +0,0 @@
/**
* 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 type {TranslationFileContent} from '@docusaurus/types';
export function extractThemeCodeMessages(
targetDirs?: string[],
): Promise<TranslationFileContent>;

View file

@ -1,353 +0,0 @@
/**
* 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.
*/
// @ts-check
// TODO convert this to ESM, which would also allow TLA
/* eslint-disable import/no-extraneous-dependencies, no-restricted-properties */
const logger = require('@docusaurus/logger').default;
const path = require('path');
const fs = require('fs-extra');
const _ = require('lodash');
const LocalesDirPath = path.join(__dirname, 'locales');
const Themes = [
{
name: 'theme-common',
src: [
getPackageCodePath('docusaurus-theme-classic'),
getPackageCodePath('docusaurus-theme-common'),
],
},
{
name: 'theme-search-algolia',
src: [getPackageCodePath('docusaurus-theme-search-algolia')],
},
{
name: 'theme-live-codeblock',
src: [getPackageCodePath('docusaurus-theme-live-codeblock')],
},
{
name: 'plugin-pwa',
src: [getPackageCodePath('docusaurus-plugin-pwa')],
},
{
name: 'plugin-ideal-image',
src: [getPackageCodePath('docusaurus-plugin-ideal-image')],
},
];
const AllThemesSrcDirs = Themes.flatMap((theme) => theme.src);
logger.info`Will scan folders for code translations:path=${AllThemesSrcDirs}`;
/**
* @param {string} packageName
*/
function getPackageCodePath(packageName) {
const packagePath = path.join(__dirname, '..', packageName);
const packageJsonPath = path.join(packagePath, 'package.json');
const {main} = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const packageSrcPath = path.join(packagePath, path.dirname(main));
const packageLibNextPath = packageSrcPath.replace('lib', 'lib-next');
return fs.existsSync(packageLibNextPath)
? packageLibNextPath
: packageSrcPath;
}
/**
* @param {string} locale
* @param {string} themeName
*/
function getThemeLocalePath(locale, themeName) {
return path.join(LocalesDirPath, locale, `${themeName}.json`);
}
/**
* @param {string} key
*/
function removeDescriptionSuffix(key) {
if (key.replace('___DESCRIPTION', '')) {
return key.replace('___DESCRIPTION', '');
}
return key;
}
/**
* @param {Record<string, string>} obj
*/
function sortObjectKeys(obj) {
let keys = Object.keys(obj);
keys = _.orderBy(keys, [(k) => removeDescriptionSuffix(k)]);
return keys.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {});
}
/**
* @param {string[]} targetDirs
* @returns {Promise<import('@docusaurus/types').TranslationFileContent>}
*/
async function extractThemeCodeMessages(targetDirs = AllThemesSrcDirs) {
// Unsafe import, should we create a package for the translationsExtractor ?
const {
globSourceCodeFilePaths,
extractAllSourceCodeFileTranslations,
// eslint-disable-next-line global-require
} = require('@docusaurus/core/lib/server/translations/translationsExtractor');
const filePaths = (await globSourceCodeFilePaths(targetDirs)).filter(
(filePath) => ['.js', '.jsx'].includes(path.extname(filePath)),
);
const filesExtractedTranslations = await extractAllSourceCodeFileTranslations(
filePaths,
{
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
},
);
filesExtractedTranslations.forEach((fileExtractedTranslations) => {
fileExtractedTranslations.warnings.forEach((warning) => {
throw new Error(`
Please make sure all theme translations are static!
Some warnings were found!
${warning}
`);
});
});
const translations = filesExtractedTranslations.reduce(
(acc, extractedTranslations) => ({
...acc,
...extractedTranslations.translations,
}),
{},
);
return translations;
}
/**
* @param {string} filePath
* @returns {Promise<Record<string, string>>}
*/
async function readMessagesFile(filePath) {
if (!(await fs.pathExists(filePath))) {
logger.info`File path=${filePath} not found. Creating new translation base file.`;
await fs.outputFile(filePath, '{}\n');
}
return JSON.parse((await fs.readFile(filePath)).toString());
}
/**
* @param {string} filePath
* @param {Record<string, string>} messages
*/
async function writeMessagesFile(filePath, messages) {
const sortedMessages = sortObjectKeys(messages);
const content = `${JSON.stringify(sortedMessages, null, 2)}\n`; // \n makes prettier happy
await fs.outputFile(filePath, content);
logger.info`path=${path.basename(
filePath,
)} updated subdue=${logger.interpolate`(number=${
Object.keys(sortedMessages).length
} messages)`}\n`;
}
/**
* @param {string} themeName
*/
async function getCodeTranslationFiles(themeName) {
const baseFile = getThemeLocalePath('base', themeName);
const localesFiles = (await fs.readdir(LocalesDirPath))
.filter((dirName) => dirName !== 'base')
.map((locale) => getThemeLocalePath(locale, themeName));
return {baseFile, localesFiles};
}
const DescriptionSuffix = '___DESCRIPTION';
/**
* @param {string} baseFile
* @param {string[]} targetDirs
*/
async function updateBaseFile(baseFile, targetDirs) {
const baseMessagesWithDescriptions = await readMessagesFile(baseFile);
const baseMessages = _.pickBy(
baseMessagesWithDescriptions,
(v, key) => !key.endsWith(DescriptionSuffix),
);
const codeExtractedTranslations = await extractThemeCodeMessages(targetDirs);
const codeMessages = _.mapValues(
codeExtractedTranslations,
(translation) => translation.message,
);
const unknownMessages = _.difference(
Object.keys(baseMessages),
Object.keys(codeMessages),
);
if (unknownMessages.length) {
logger.error`Some messages exist in base locale but were not found by the code extractor!
They won't be removed automatically, so do the cleanup manually if necessary! code=${unknownMessages}`;
}
const newBaseMessages = {
...baseMessages, // Ensure we don't automatically remove unknown messages
...codeMessages,
};
/** @type {Record<string, string>} */
const newBaseMessagesDescriptions = Object.entries(newBaseMessages).reduce(
(acc, [key]) => {
const codeTranslation = codeExtractedTranslations[key];
return {
...acc,
[`${key}${DescriptionSuffix}`]: codeTranslation
? codeTranslation.description
: undefined,
};
},
{},
);
const newBaseMessagesWitDescription = {
...newBaseMessages,
...newBaseMessagesDescriptions,
};
await writeMessagesFile(baseFile, newBaseMessagesWitDescription);
return newBaseMessages;
}
/**
* @param {string} localeFile
* @param {Record<string, string>} baseFileMessages
*/
async function updateLocaleCodeTranslations(localeFile, baseFileMessages) {
const localeFileMessages = await readMessagesFile(localeFile);
const unknownMessages = _.difference(
Object.keys(localeFileMessages),
Object.keys(baseFileMessages),
);
if (unknownMessages.length) {
logger.error`Some localized messages do not exist in base.json!
You may want to delete these! code=${unknownMessages}`;
}
const newLocaleFileMessages = {
...baseFileMessages,
...localeFileMessages,
};
const untranslatedKeys = Object.entries(newLocaleFileMessages)
.filter(([key, value]) => value === baseFileMessages[key])
.map(([key]) => key);
if (untranslatedKeys.length) {
logger.warn`Some messages do not seem to be translated! code=${untranslatedKeys}`;
}
await writeMessagesFile(localeFile, newLocaleFileMessages);
return {untranslated: untranslatedKeys.length};
}
async function updateCodeTranslations() {
/** @type {Record<string, {untranslated: number}>} */
const stats = {};
let messageCount = 0;
const {2: newLocale} = process.argv;
for (const theme of Themes) {
const {baseFile, localesFiles} = await getCodeTranslationFiles(theme.name);
logger.info`Will update base file for name=${theme.name}\n`;
const baseFileMessages = await updateBaseFile(baseFile, theme.src);
if (newLocale) {
const newLocalePath = getThemeLocalePath(newLocale, theme.name);
if (!fs.existsSync(newLocalePath)) {
await writeMessagesFile(newLocalePath, baseFileMessages);
logger.success`Locale file path=${path.basename(
newLocalePath,
)} have been created.`;
} else {
logger.warn`Locale file path=${path.basename(
newLocalePath,
)} was already created!`;
}
} else {
for (const localeFile of localesFiles) {
const localeName = path.basename(path.dirname(localeFile));
const pluginName = path.basename(localeFile, path.extname(localeFile));
logger.info`Will update name=${localeName} locale in name=${pluginName}`;
const stat = await updateLocaleCodeTranslations(
localeFile,
baseFileMessages,
);
stats[localeName] ??= {untranslated: 0};
stats[localeName].untranslated += stat.untranslated;
}
messageCount += Object.keys(baseFileMessages).length;
}
}
if (newLocale) {
return null;
}
return {stats, messageCount};
}
if (require.main === module) {
updateCodeTranslations().then(
(result) => {
logger.success('updateCodeTranslations end\n');
if (result) {
const {stats, messageCount} = result;
const locales = Object.entries(stats).sort(
(a, b) => a[1].untranslated - b[1].untranslated,
);
const messages = locales.map(([name, stat]) => {
const percentage = (messageCount - stat.untranslated) / messageCount;
const filled = Math.floor(percentage * 30);
const color =
// eslint-disable-next-line no-nested-ternary
percentage > 0.99
? logger.green
: percentage > 0.7
? logger.yellow
: logger.red;
const progress = color(
`[${''.padStart(filled, '=')}${''.padStart(30 - filled, ' ')}]`,
);
return logger.interpolate`name=${name.padStart(8)} ${progress} ${(
percentage * 100
).toFixed(1)} subdue=${`(${
messageCount - stat.untranslated
}/${messageCount})`}`;
});
logger.info`Translation coverage:
${messages.join('\n')}`;
}
},
(e) => {
logger.error(
`\nupdateCodeTranslations failure: ${e.message}\n${e.stack}\n`,
);
process.exit(1);
},
);
}
exports.extractThemeCodeMessages = extractThemeCodeMessages;

View file

@ -0,0 +1,247 @@
/**
* 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.
*/
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import logger from '@docusaurus/logger';
import path from 'path';
import {fileURLToPath} from 'url';
import fs from 'fs-extra';
import _ from 'lodash';
import {getThemes, extractThemeCodeMessages} from './lib/utils.js';
const LocalesDirPath = fileURLToPath(new URL('locales', import.meta.url));
const Themes = await getThemes();
const AllThemesSrcDirs = Themes.flatMap((theme) => theme.src);
logger.info`Will scan folders for code translations:path=${AllThemesSrcDirs}`;
/**
* @param {string} locale
* @param {string} themeName
*/
function getThemeLocalePath(locale, themeName) {
return path.join(LocalesDirPath, locale, `${themeName}.json`);
}
/**
* @param {string} key
*/
function removeDescriptionSuffix(key) {
if (key.replace('___DESCRIPTION', '')) {
return key.replace('___DESCRIPTION', '');
}
return key;
}
/**
* @param {Record<string, string>} obj
*/
function sortObjectKeys(obj) {
const keys = _.orderBy(Object.keys(obj), (k) => removeDescriptionSuffix(k));
return Object.fromEntries(keys.map((k) => [k, obj[k]]));
}
/**
* @param {string} filePath
* @returns {Promise<Record<string, string>>}
*/
async function readMessagesFile(filePath) {
if (!(await fs.pathExists(filePath))) {
logger.info`File path=${filePath} not found. Creating new translation base file.`;
await fs.outputFile(filePath, '{}\n');
}
return JSON.parse((await fs.readFile(filePath)).toString());
}
/**
* @param {string} filePath
* @param {Record<string, string>} messages
*/
async function writeMessagesFile(filePath, messages) {
const sortedMessages = sortObjectKeys(messages);
const content = `${JSON.stringify(sortedMessages, null, 2)}\n`; // \n makes prettier happy
await fs.outputFile(filePath, content);
logger.info`path=${path.basename(
filePath,
)} updated subdue=${logger.interpolate`(number=${
Object.keys(sortedMessages).length
} messages)`}\n`;
}
/**
* @param {string} themeName
*/
async function getCodeTranslationFiles(themeName) {
const baseFile = getThemeLocalePath('base', themeName);
const localesFiles = (await fs.readdir(LocalesDirPath))
.filter((dirName) => dirName !== 'base' && !dirName.startsWith('__'))
.map((locale) => getThemeLocalePath(locale, themeName));
return {baseFile, localesFiles};
}
const DescriptionSuffix = '___DESCRIPTION';
/**
* @param {string} baseFile
* @param {string[]} targetDirs
*/
async function updateBaseFile(baseFile, targetDirs) {
const baseMessagesWithDescriptions = await readMessagesFile(baseFile);
const baseMessages = _.pickBy(
baseMessagesWithDescriptions,
(v, key) => !key.endsWith(DescriptionSuffix),
);
const codeExtractedTranslations = await extractThemeCodeMessages(targetDirs);
const codeMessages = _.mapValues(
codeExtractedTranslations,
(translation) => translation.message,
);
const unknownMessages = _.difference(
Object.keys(baseMessages),
Object.keys(codeMessages),
);
if (unknownMessages.length) {
logger.error`Some messages exist in base locale but were not found by the code extractor!
They won't be removed automatically, so do the cleanup manually if necessary! code=${unknownMessages}`;
}
const newBaseMessages = {
...baseMessages, // Ensure we don't automatically remove unknown messages
...codeMessages,
};
/** @type {Record<string, string>} */
const newBaseMessagesDescriptions = Object.entries(newBaseMessages).reduce(
(acc, [key]) => {
const codeTranslation = codeExtractedTranslations[key];
return {
...acc,
[`${key}${DescriptionSuffix}`]: codeTranslation
? codeTranslation.description
: undefined,
};
},
{},
);
const newBaseMessagesWitDescription = {
...newBaseMessages,
...newBaseMessagesDescriptions,
};
await writeMessagesFile(baseFile, newBaseMessagesWitDescription);
return newBaseMessages;
}
/**
* @param {string} localeFile
* @param {Record<string, string>} baseFileMessages
*/
async function updateLocaleCodeTranslations(localeFile, baseFileMessages) {
const localeFileMessages = await readMessagesFile(localeFile);
const unknownMessages = _.difference(
Object.keys(localeFileMessages),
Object.keys(baseFileMessages),
);
if (unknownMessages.length) {
logger.error`Some localized messages do not exist in base.json!
You may want to delete these! code=${unknownMessages}`;
}
const newLocaleFileMessages = {
...baseFileMessages,
...localeFileMessages,
};
const untranslatedKeys = Object.entries(newLocaleFileMessages)
.filter(([key, value]) => value === baseFileMessages[key])
.map(([key]) => key);
if (untranslatedKeys.length) {
logger.warn`Some messages do not seem to be translated! code=${untranslatedKeys}`;
}
await writeMessagesFile(localeFile, newLocaleFileMessages);
return {untranslated: untranslatedKeys.length};
}
/** @type {Record<string, {untranslated: number}>} */
const stats = {};
let messageCount = 0;
const {2: newLocale} = process.argv;
for (const theme of Themes) {
const {baseFile, localesFiles} = await getCodeTranslationFiles(theme.name);
logger.info`Will update base file for name=${theme.name}\n`;
const baseFileMessages = await updateBaseFile(baseFile, theme.src);
if (newLocale) {
const newLocalePath = getThemeLocalePath(newLocale, theme.name);
if (!(await fs.pathExists(newLocalePath))) {
await writeMessagesFile(newLocalePath, baseFileMessages);
logger.success`Locale file path=${path.basename(
newLocalePath,
)} have been created.`;
} else {
logger.warn`Locale file path=${path.basename(
newLocalePath,
)} was already created!`;
}
} else {
for (const localeFile of localesFiles) {
const localeName = path.basename(path.dirname(localeFile));
const pluginName = path.basename(localeFile, path.extname(localeFile));
logger.info`Will update name=${localeName} locale in name=${pluginName}`;
const stat = await updateLocaleCodeTranslations(
localeFile,
baseFileMessages,
);
(stats[localeName] ??= {untranslated: 0}).untranslated +=
stat.untranslated;
}
messageCount += Object.keys(baseFileMessages).length;
}
}
logger.success('updateCodeTranslations end\n');
if (newLocale) {
process.exit();
}
const locales = Object.entries(stats).sort(
(a, b) => a[1].untranslated - b[1].untranslated,
);
const messages = locales.map(([name, stat]) => {
const percentage = (messageCount - stat.untranslated) / messageCount;
const filled = Math.floor(percentage * 30);
const color =
// eslint-disable-next-line no-nested-ternary
percentage > 0.99
? logger.green
: percentage > 0.7
? logger.yellow
: logger.red;
const progress = color(
`[${''.padStart(filled, '=')}${''.padStart(30 - filled, ' ')}]`,
);
return logger.interpolate`name=${name.padStart(8)} ${progress} ${(
percentage * 100
).toFixed(1)} subdue=${`(${
messageCount - stat.untranslated
}/${messageCount})`}`;
});
logger.info`Translation coverage:
${messages.join('\n')}`;

View file

@ -288,7 +288,7 @@ export interface Plugin<Content = unknown> {
}
export type InitializedPlugin<Content = unknown> = Plugin<Content> & {
readonly options: PluginOptions;
readonly options: Required<PluginOptions>;
readonly version: DocusaurusPluginVersionInformation;
};

View file

@ -8,7 +8,107 @@
import {jest} from '@jest/globals';
import Joi from '../Joi';
import {JoiFrontMatter} from '../JoiFrontMatter';
import {validateFrontMatter} from '../validationUtils';
import {
normalizePluginOptions,
normalizeThemeConfig,
validateFrontMatter,
} from '../validationUtils';
describe('normalizePluginOptions', () => {
it('always adds an "id" field', () => {
const options = {id: 'a'};
expect(
normalizePluginOptions(
// "Malicious" schema that tries to forbid "id"
Joi.object({id: Joi.any().forbidden()}),
options,
),
).toEqual(options);
});
it('normalizes plugin options', () => {
const options = {};
expect(
normalizePluginOptions(
Joi.object({foo: Joi.string().default('a')}),
options,
),
).toEqual({foo: 'a', id: 'default'});
});
it('throws for invalid options', () => {
const options = {foo: 1};
expect(() =>
normalizePluginOptions(Joi.object({foo: Joi.string()}), options),
).toThrowErrorMatchingInlineSnapshot(`"\\"foo\\" must be a string"`);
});
it('warns', () => {
const options = {foo: 'a'};
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
expect(
normalizePluginOptions(
Joi.object({foo: Joi.string().warning('deprecated', {})}).messages({
deprecated: '{#label} deprecated',
}),
options,
),
).toEqual({foo: 'a', id: 'default'});
expect(consoleMock).toBeCalledWith(
expect.stringMatching(/"foo" deprecated/),
);
});
});
describe('normalizeThemeConfig', () => {
it('always allows unknown attributes', () => {
const themeConfig = {foo: 'a', bar: 1};
expect(
normalizeThemeConfig(
// "Malicious" schema that tries to forbid extra properties
Joi.object({foo: Joi.string()}).unknown(false),
themeConfig,
),
).toEqual(themeConfig);
});
it('normalizes theme config', () => {
const themeConfig = {bar: 1};
expect(
normalizeThemeConfig(
Joi.object({foo: Joi.string().default('a')}),
themeConfig,
),
).toEqual({bar: 1, foo: 'a'});
});
it('throws for invalid options', () => {
const themeConfig = {foo: 1, bar: 1};
expect(() =>
normalizeThemeConfig(Joi.object({foo: Joi.string()}), themeConfig),
).toThrowErrorMatchingInlineSnapshot(`"\\"foo\\" must be a string"`);
});
it('warns', () => {
const themeConfig = {foo: 'a', bar: 1};
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
expect(
normalizeThemeConfig(
Joi.object({foo: Joi.string().warning('deprecated', {})}).messages({
deprecated: '{#label} deprecated',
}),
themeConfig,
),
).toEqual(themeConfig);
expect(consoleMock).toBeCalledWith(
expect.stringMatching(/"foo" deprecated/),
);
});
});
describe('validateFrontMatter', () => {
it('accepts good values', () => {

View file

@ -44,7 +44,7 @@ export function normalizeThemeConfig<T>(
schema: Joi.ObjectSchema<T>,
themeConfig: Partial<T>,
): T {
// A theme should only validate his "slice" of the full themeConfig,
// A theme should only validate its "slice" of the full themeConfig,
// not the whole object, so we allow unknown attributes
// otherwise one theme would fail validating the data of another theme
const finalSchema = schema.unknown();

View file

@ -10,7 +10,8 @@ import {
parseMarkdownContentTitle,
parseMarkdownString,
parseMarkdownHeadingId,
} from '../markdownParser';
writeMarkdownHeadingId,
} from '../markdownUtils';
import dedent from 'dedent';
describe('createExcerpt', () => {
@ -801,3 +802,108 @@ describe('parseMarkdownHeadingId', () => {
});
});
});
describe('writeMarkdownHeadingId', () => {
it('works for simple level-2 heading', () => {
expect(writeMarkdownHeadingId('## ABC')).toBe('## ABC {#abc}');
});
it('works for simple level-3 heading', () => {
expect(writeMarkdownHeadingId('### ABC')).toBe('### ABC {#abc}');
});
it('works for simple level-4 heading', () => {
expect(writeMarkdownHeadingId('#### ABC')).toBe('#### ABC {#abc}');
});
it('unwraps markdown links', () => {
const input = `## hello [facebook](https://facebook.com) [crowdin](https://crowdin.com/translate/docusaurus-v2/126/en-fr?filter=basic&value=0)`;
expect(writeMarkdownHeadingId(input)).toBe(
`${input} {#hello-facebook-crowdin}`,
);
});
it('can slugify complex headings', () => {
const input = '## abc [Hello] How are you %Sébastien_-_$)( ## -56756';
expect(writeMarkdownHeadingId(input)).toBe(
// cSpell:ignore ébastien
`${input} {#abc-hello-how-are-you-sébastien_-_---56756}`,
);
});
it('does not duplicate duplicate id', () => {
expect(writeMarkdownHeadingId('## hello world {#hello-world}')).toBe(
'## hello world {#hello-world}',
);
});
it('respects existing heading', () => {
expect(writeMarkdownHeadingId('## New heading {#old-heading}')).toBe(
'## New heading {#old-heading}',
);
});
it('overwrites heading ID when asked to', () => {
expect(
writeMarkdownHeadingId('## New heading {#old-heading}', {
overwrite: true,
}),
).toBe('## New heading {#new-heading}');
});
it('maintains casing when asked to', () => {
expect(
writeMarkdownHeadingId('## getDataFromAPI()', {
maintainCase: true,
}),
).toBe('## getDataFromAPI() {#getDataFromAPI}');
});
it('transform the headings', () => {
const input = `
# Ignored title
## abc
### Hello world
\`\`\`
# Heading in code block
\`\`\`
## Hello world
\`\`\`
# Heading in escaped code block
\`\`\`
### abc {#abc}
`;
const expected = `
# Ignored title
## abc {#abc-1}
### Hello world {#hello-world}
\`\`\`
# Heading in code block
\`\`\`
## Hello world {#hello-world-1}
\`\`\`
# Heading in escaped code block
\`\`\`
### abc {#abc}
`;
expect(writeMarkdownHeadingId(input)).toEqual(expected);
});
});

View file

@ -15,11 +15,18 @@ import {
removeTrailingSlash,
resolvePathname,
encodePath,
buildSshUrl,
buildHttpsUrl,
hasSSHProtocol,
} from '../urlUtils';
describe('normalizeUrl', () => {
it('normalizes urls correctly', () => {
const asserts = [
{
input: [],
output: '',
},
{
input: ['/', ''],
output: '/',
@ -248,3 +255,60 @@ describe('encodePath', () => {
expect(encodePath('a/你好/')).toBe('a/%E4%BD%A0%E5%A5%BD/');
});
});
describe('buildSshUrl', () => {
it('builds a normal ssh url', () => {
const url = buildSshUrl('github.com', 'facebook', 'docusaurus');
expect(url).toBe('git@github.com:facebook/docusaurus.git');
});
it('builds a ssh url with port', () => {
const url = buildSshUrl('github.com', 'facebook', 'docusaurus', '422');
expect(url).toBe('ssh://git@github.com:422/facebook/docusaurus.git');
});
});
describe('buildHttpsUrl', () => {
it('builds a normal http url', () => {
const url = buildHttpsUrl(
'user:pass',
'github.com',
'facebook',
'docusaurus',
);
expect(url).toBe('https://user:pass@github.com/facebook/docusaurus.git');
});
it('builds a normal http url with port', () => {
const url = buildHttpsUrl(
'user:pass',
'github.com',
'facebook',
'docusaurus',
'5433',
);
expect(url).toBe(
'https://user:pass@github.com:5433/facebook/docusaurus.git',
);
});
});
describe('hasSSHProtocol', () => {
it('recognizes explicit SSH protocol', () => {
const url = 'ssh://git@github.com:422/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(true);
});
it('recognizes implied SSH protocol', () => {
const url = 'git@github.com:facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(true);
});
it('does not recognize HTTPS with credentials', () => {
const url = 'https://user:pass@github.com/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(false);
});
it('does not recognize plain HTTPS URL', () => {
const url = 'https://github.com:5433/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(false);
});
});

View file

@ -45,6 +45,9 @@ export {
addLeadingSlash,
addTrailingSlash,
removeTrailingSlash,
hasSSHProtocol,
buildHttpsUrl,
buildSshUrl,
} from './urlUtils';
export {
type Tag,
@ -60,7 +63,9 @@ export {
parseFrontMatter,
parseMarkdownContentTitle,
parseMarkdownString,
} from './markdownParser';
writeMarkdownHeadingId,
type WriteHeadingIDOptions,
} from './markdownUtils';
export {
type ContentPaths,
type BrokenMarkdownLink,

View file

@ -7,6 +7,7 @@
import logger from '@docusaurus/logger';
import matter from 'gray-matter';
import {createSlugger, type Slugger} from './slugger';
// Input: ## Some heading {#some-heading}
// Output: {text: "## Some heading", id: "some-heading"}
@ -205,3 +206,72 @@ This can happen if you use special characters in front matter values (try using
throw err;
}
}
function unwrapMarkdownLinks(line: string): string {
return line.replace(/\[(?<alt>[^\]]+)\]\([^)]+\)/g, (match, p1) => p1);
}
function addHeadingId(
line: string,
slugger: Slugger,
maintainCase: boolean,
): string {
let headingLevel = 0;
while (line.charAt(headingLevel) === '#') {
headingLevel += 1;
}
const headingText = line.slice(headingLevel).trimEnd();
const headingHashes = line.slice(0, headingLevel);
const slug = slugger.slug(unwrapMarkdownLinks(headingText).trim(), {
maintainCase,
});
return `${headingHashes}${headingText} {#${slug}}`;
}
export type WriteHeadingIDOptions = {
maintainCase?: boolean;
overwrite?: boolean;
};
export function writeMarkdownHeadingId(
content: string,
options: WriteHeadingIDOptions = {maintainCase: false, overwrite: false},
): string {
const {maintainCase = false, overwrite = false} = options;
const lines = content.split('\n');
const slugger = createSlugger();
// If we can't overwrite existing slugs, make sure other headings don't
// generate colliding slugs by first marking these slugs as occupied
if (!overwrite) {
lines.forEach((line) => {
const parsedHeading = parseMarkdownHeadingId(line);
if (parsedHeading.id) {
slugger.slug(parsedHeading.id);
}
});
}
let inCode = false;
return lines
.map((line) => {
if (line.startsWith('```')) {
inCode = !inCode;
return line;
}
// Ignore h1 headings, as we don't create anchor links for those
if (inCode || !line.startsWith('##')) {
return line;
}
const parsedHeading = parseMarkdownHeadingId(line);
// Do not process if id is already there
if (parsedHeading.id && !overwrite) {
return line;
}
return addHeadingId(parsedHeading.text, slugger, maintainCase);
})
.join('\n');
}

View file

@ -154,3 +154,40 @@ export function addTrailingSlash(str: string): string {
export function removeTrailingSlash(str: string): string {
return removeSuffix(str, '/');
}
export function buildSshUrl(
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `ssh://git@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `git@${githubHost}:${organizationName}/${projectName}.git`;
}
export function buildHttpsUrl(
gitCredentials: string,
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `https://${gitCredentials}@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `https://${gitCredentials}@${githubHost}/${organizationName}/${projectName}.git`;
}
export function hasSSHProtocol(sourceRepoUrl: string): boolean {
try {
if (new URL(sourceRepoUrl).protocol === 'ssh:') {
return true;
}
return false;
} catch {
// Fails when there isn't a protocol
return /^(?:[\w-]+@)?[\w.-]+:[\w./-]+/.test(sourceRepoUrl); // git@github.com:facebook/docusaurus.git
}
}

View file

@ -98,9 +98,7 @@ export default async function beforeCli() {
.filter((p) => p.startsWith('@docusaurus'))
.map((p) => p.concat('@latest'))
.join(' ');
const isYarnUsed = await fs.pathExists(
path.resolve(process.cwd(), 'yarn.lock'),
);
const isYarnUsed = await fs.pathExists(path.resolve('yarn.lock'));
const upgradeCommand = isYarnUsed
? `yarn upgrade ${siteDocusaurusPackagesForUpdate}`
: `npm i ${siteDocusaurusPackagesForUpdate}`;

View file

@ -1,62 +0,0 @@
/**
* 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 {buildSshUrl, buildHttpsUrl, hasSSHProtocol} from '../deploy';
describe('remoteBranchUrl', () => {
it('builds a normal ssh url', () => {
const url = buildSshUrl('github.com', 'facebook', 'docusaurus');
expect(url).toBe('git@github.com:facebook/docusaurus.git');
});
it('builds a ssh url with port', () => {
const url = buildSshUrl('github.com', 'facebook', 'docusaurus', '422');
expect(url).toBe('ssh://git@github.com:422/facebook/docusaurus.git');
});
it('builds a normal http url', () => {
const url = buildHttpsUrl(
'user:pass',
'github.com',
'facebook',
'docusaurus',
);
expect(url).toBe('https://user:pass@github.com/facebook/docusaurus.git');
});
it('builds a normal http url with port', () => {
const url = buildHttpsUrl(
'user:pass',
'github.com',
'facebook',
'docusaurus',
'5433',
);
expect(url).toBe(
'https://user:pass@github.com:5433/facebook/docusaurus.git',
);
});
});
describe('hasSSHProtocol', () => {
it('recognizes explicit SSH protocol', () => {
const url = 'ssh://git@github.com:422/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(true);
});
it('recognizes implied SSH protocol', () => {
const url = 'git@github.com:facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(true);
});
it('does not recognize HTTPS with credentials', () => {
const url = 'https://user:pass@github.com/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(false);
});
it('does not recognize plain HTTPS URL', () => {
const url = 'https://github.com:5433/facebook/docusaurus.git';
expect(hasSSHProtocol(url)).toBe(false);
});
});

View file

@ -1,113 +0,0 @@
/**
* 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 {transformMarkdownContent} from '../writeHeadingIds';
describe('transformMarkdownContent', () => {
it('works for simple level-2 heading', () => {
expect(transformMarkdownContent('## ABC')).toBe('## ABC {#abc}');
});
it('works for simple level-3 heading', () => {
expect(transformMarkdownContent('### ABC')).toBe('### ABC {#abc}');
});
it('works for simple level-4 heading', () => {
expect(transformMarkdownContent('#### ABC')).toBe('#### ABC {#abc}');
});
it('unwraps markdown links', () => {
const input = `## hello [facebook](https://facebook.com) [crowdin](https://crowdin.com/translate/docusaurus-v2/126/en-fr?filter=basic&value=0)`;
expect(transformMarkdownContent(input)).toBe(
`${input} {#hello-facebook-crowdin}`,
);
});
it('can slugify complex headings', () => {
const input = '## abc [Hello] How are you %Sébastien_-_$)( ## -56756';
expect(transformMarkdownContent(input)).toBe(
// cSpell:ignore ébastien
`${input} {#abc-hello-how-are-you-sébastien_-_---56756}`,
);
});
it('does not duplicate duplicate id', () => {
expect(transformMarkdownContent('## hello world {#hello-world}')).toBe(
'## hello world {#hello-world}',
);
});
it('respects existing heading', () => {
expect(transformMarkdownContent('## New heading {#old-heading}')).toBe(
'## New heading {#old-heading}',
);
});
it('overwrites heading ID when asked to', () => {
expect(
transformMarkdownContent('## New heading {#old-heading}', {
overwrite: true,
}),
).toBe('## New heading {#new-heading}');
});
it('maintains casing when asked to', () => {
expect(
transformMarkdownContent('## getDataFromAPI()', {
maintainCase: true,
}),
).toBe('## getDataFromAPI() {#getDataFromAPI}');
});
it('transform the headings', () => {
const input = `
# Ignored title
## abc
### Hello world
\`\`\`
# Heading in code block
\`\`\`
## Hello world
\`\`\`
# Heading in escaped code block
\`\`\`
### abc {#abc}
`;
const expected = `
# Ignored title
## abc {#abc-1}
### Hello world {#hello-world}
\`\`\`
# Heading in code block
\`\`\`
## Hello world {#hello-world-1}
\`\`\`
# Heading in escaped code block
\`\`\`
### abc {#abc}
`;
expect(transformMarkdownContent(input)).toEqual(expected);
});
});

View file

@ -8,6 +8,7 @@
import fs from 'fs-extra';
import shell from 'shelljs';
import logger from '@docusaurus/logger';
import {hasSSHProtocol, buildSshUrl, buildHttpsUrl} from '@docusaurus/utils';
import {loadContext} from '../server';
import build from './build';
import type {BuildCLIOptions} from '@docusaurus/types';
@ -33,43 +34,6 @@ function shellExecLog(cmd: string) {
}
}
export function buildSshUrl(
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `ssh://git@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `git@${githubHost}:${organizationName}/${projectName}.git`;
}
export function buildHttpsUrl(
gitCredentials: string,
githubHost: string,
organizationName: string,
projectName: string,
githubPort?: string,
): string {
if (githubPort) {
return `https://${gitCredentials}@${githubHost}:${githubPort}/${organizationName}/${projectName}.git`;
}
return `https://${gitCredentials}@${githubHost}/${organizationName}/${projectName}.git`;
}
export function hasSSHProtocol(sourceRepoUrl: string): boolean {
try {
if (new URL(sourceRepoUrl).protocol === 'ssh:') {
return true;
}
return false;
} catch {
// Fails when there isn't a protocol
return /^(?:[\w-]+@)?[\w.-]+:[\w./-]+/.test(sourceRepoUrl); // git@github.com:facebook/docusaurus.git
}
}
export default async function deploy(
siteDir: string,
cliOptions: Partial<BuildCLIOptions> = {},

View file

@ -123,7 +123,7 @@ export default async function start(
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
template: path.resolve(
template: path.join(
__dirname,
'../webpack/templates/index.html.template.ejs',
),

View file

@ -7,91 +7,20 @@
import fs from 'fs-extra';
import logger from '@docusaurus/logger';
import {
writeMarkdownHeadingId,
type WriteHeadingIDOptions,
} from '@docusaurus/utils';
import {loadContext, loadPluginConfigs} from '../server';
import initPlugins from '../server/plugins/init';
import {
parseMarkdownHeadingId,
createSlugger,
type Slugger,
} from '@docusaurus/utils';
import {safeGlobby} from '../server/utils';
type Options = {
maintainCase?: boolean;
overwrite?: boolean;
};
function unwrapMarkdownLinks(line: string): string {
return line.replace(/\[(?<alt>[^\]]+)\]\([^)]+\)/g, (match, p1) => p1);
}
function addHeadingId(
line: string,
slugger: Slugger,
maintainCase: boolean,
): string {
let headingLevel = 0;
while (line.charAt(headingLevel) === '#') {
headingLevel += 1;
}
const headingText = line.slice(headingLevel).trimEnd();
const headingHashes = line.slice(0, headingLevel);
const slug = slugger.slug(unwrapMarkdownLinks(headingText).trim(), {
maintainCase,
});
return `${headingHashes}${headingText} {#${slug}}`;
}
export function transformMarkdownContent(
content: string,
options: Options = {maintainCase: false, overwrite: false},
): string {
const {maintainCase = false, overwrite = false} = options;
const lines = content.split('\n');
const slugger = createSlugger();
// If we can't overwrite existing slugs, make sure other headings don't
// generate colliding slugs by first marking these slugs as occupied
if (!overwrite) {
lines.forEach((line) => {
const parsedHeading = parseMarkdownHeadingId(line);
if (parsedHeading.id) {
slugger.slug(parsedHeading.id);
}
});
}
let inCode = false;
return lines
.map((line) => {
if (line.startsWith('```')) {
inCode = !inCode;
return line;
}
// Ignore h1 headings, as we don't create anchor links for those
if (inCode || !line.startsWith('##')) {
return line;
}
const parsedHeading = parseMarkdownHeadingId(line);
// Do not process if id is already there
if (parsedHeading.id && !overwrite) {
return line;
}
return addHeadingId(parsedHeading.text, slugger, maintainCase);
})
.join('\n');
}
async function transformMarkdownFile(
filepath: string,
options?: Options,
options?: WriteHeadingIDOptions,
): Promise<string | undefined> {
const content = await fs.readFile(filepath, 'utf8');
const updatedContent = transformMarkdownContent(content, options);
const updatedContent = writeMarkdownHeadingId(content, options);
if (content !== updatedContent) {
await fs.writeFile(filepath, updatedContent);
return filepath;
@ -118,7 +47,7 @@ async function getPathsToWatch(siteDir: string): Promise<string[]> {
export default async function writeHeadingIds(
siteDir: string,
files?: string[],
options?: Options,
options?: WriteHeadingIDOptions,
): Promise<void> {
const markdownFiles = await safeGlobby(
files ?? (await getPathsToWatch(siteDir)),

View file

@ -21,7 +21,7 @@ exports[`loadRoutes loads flat route config 1`] = `
},
},
"routesChunkNames": {
"/blog-94e": {
"/blog-1e7": {
"component": "component---theme-blog-list-pagea-6-a-7ba",
"items": [
{
@ -32,6 +32,10 @@ exports[`loadRoutes loads flat route config 1`] = `
"content": "content---blog-7-b-8-fd9",
"metadata": null,
},
{
"content": "content---blog-7-b-8-fd9",
"metadata": null,
},
],
},
},
@ -42,7 +46,7 @@ import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '/blog',
component: ComponentCreator('/blog','94e'),
component: ComponentCreator('/blog','1e7'),
exact: true
},
{
@ -96,11 +100,11 @@ exports[`loadRoutes loads nested route config 1`] = `
"content": "content---docs-helloaff-811",
"metadata": "metadata---docs-hello-956-741",
},
"/docs:route-63b": {
"/docs:route-52d": {
"component": "component---theme-doc-page-1-be-9be",
"docsMetadata": "docsMetadata---docs-routef-34-881",
},
"docs/foo/baz-ac2": {
"docs/foo/baz-070": {
"component": "component---theme-doc-item-178-a40",
"content": "content---docs-foo-baz-8-ce-61e",
"metadata": "metadata---docs-foo-baz-2-cf-fa7",
@ -113,18 +117,23 @@ import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '/docs:route',
component: ComponentCreator('/docs:route','63b'),
component: ComponentCreator('/docs:route','52d'),
routes: [
{
path: '/docs/hello',
component: ComponentCreator('/docs/hello','44b'),
exact: true,
'sidebar': \\"main\\"
sidebar: \\"main\\"
},
{
path: 'docs/foo/baz',
component: ComponentCreator('docs/foo/baz','ac2'),
'sidebar': \\"secondary\\"
component: ComponentCreator('docs/foo/baz','070'),
sidebar: \\"secondary\\",
\\"key:a\\": \\"containing colon\\",
\\"key'b\\": \\"containing quote\\",
\\"key\\\\\\"c\\": \\"containing double quote\\",
\\"key,d\\": \\"containing comma\\",
字段: \\"containing unicode\\"
}
]
},

View file

@ -35,6 +35,11 @@ describe('loadRoutes', () => {
metadata: 'docs-foo-baz-dd9.json',
},
sidebar: 'secondary',
'key:a': 'containing colon',
"key'b": 'containing quote',
'key"c': 'containing double quote',
'key,d': 'containing comma',
: 'containing unicode',
},
],
};
@ -64,6 +69,13 @@ describe('loadRoutes', () => {
content: 'blog/2018-12-14-Happy-First-Birthday-Slash.md',
metadata: null,
},
{
content: {
__import: true,
path: 'blog/2018-12-14-Happy-First-Birthday-Slash.md',
},
metadata: null,
},
],
},
};

View file

@ -56,7 +56,7 @@ export const DEFAULT_CONFIG: Pick<
staticDirectories: [STATIC_DIR_NAME],
};
function createPluginSchema(theme: boolean = false) {
function createPluginSchema(theme: boolean) {
return (
Joi.alternatives()
.try(

View file

@ -30,7 +30,7 @@ export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig {
export async function loadI18n(
config: DocusaurusConfig,
options: {locale?: string} = {},
options: {locale?: string},
): Promise<I18n> {
const {i18n: i18nConfig} = config;
@ -88,9 +88,5 @@ export function localizePath({
return path.join(originalPath, i18n.currentLocale);
}
// Url paths; add a trailing slash so it's a valid base URL
if (pathType === 'url') {
return normalizeUrl([originalPath, i18n.currentLocale, '/']);
}
// should never happen
throw new Error(`Unhandled path type "${pathType}".`);
}

View file

@ -57,12 +57,10 @@ export async function loadSiteConfig({
siteDir: string;
customConfigFilePath?: string;
}): Promise<{siteConfig: DocusaurusConfig; siteConfigPath: string}> {
const siteConfigPathUnresolved =
customConfigFilePath ?? DEFAULT_CONFIG_FILE_NAME;
const siteConfigPath = path.isAbsolute(siteConfigPathUnresolved)
? siteConfigPathUnresolved
: path.resolve(siteDir, siteConfigPathUnresolved);
const siteConfigPath = path.resolve(
siteDir,
customConfigFilePath ?? DEFAULT_CONFIG_FILE_NAME,
);
const siteConfig = await loadConfig(siteConfigPath);
return {siteConfig, siteConfigPath};
@ -73,9 +71,7 @@ export async function loadContext(
options: LoadContextOptions = {},
): Promise<LoadContext> {
const {customOutDir, locale, customConfigFilePath} = options;
const generatedFilesDir = path.isAbsolute(GENERATED_FILES_DIR_NAME)
? GENERATED_FILES_DIR_NAME
: path.resolve(siteDir, GENERATED_FILES_DIR_NAME);
const generatedFilesDir = path.resolve(siteDir, GENERATED_FILES_DIR_NAME);
const {siteConfig: initialSiteConfig, siteConfigPath} = await loadSiteConfig({
siteDir,
@ -83,9 +79,10 @@ export async function loadContext(
});
const {ssrTemplate} = initialSiteConfig;
const baseOutDir = customOutDir
? path.resolve(customOutDir)
: path.resolve(siteDir, DEFAULT_BUILD_DIR_NAME);
const baseOutDir = path.resolve(
siteDir,
customOutDir ?? DEFAULT_BUILD_DIR_NAME,
);
const i18n = await loadI18n(initialSiteConfig, {locale});
@ -191,7 +188,9 @@ function createBootstrapPlugin({
return {
name: 'docusaurus-bootstrap-plugin',
content: null,
options: {},
options: {
id: 'default',
},
version: {type: 'synthetic'},
getClientModules() {
return siteConfigClientModules;
@ -241,7 +240,9 @@ function createMDXFallbackPlugin({
return {
name: 'docusaurus-mdx-fallback-plugin',
content: null,
options: {},
options: {
id: 'default',
},
version: {type: 'synthetic'},
configureWebpack(config, isServer, {getJSLoader}) {
// We need the mdx fallback loader to exclude files that were already

View file

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`loadPlugins loads plugins 1`] = `
{
"globalData": {
"test1": {
"default": {
"content": "a",
"prop": "a",
},
},
},
"plugins": [
{
"content": "a",
"contentLoaded": [Function],
"loadContent": [Function],
"name": "test1",
"options": {
"id": "default",
},
"prop": "a",
"version": {
"type": "local",
},
},
{
"configureWebpack": [Function],
"content": undefined,
"name": "test2",
"options": {
"id": "default",
},
"version": {
"type": "local",
},
},
],
"pluginsRouteConfigs": [],
"themeConfigTranslated": {},
}
`;
exports[`sortConfig sorts route config correctly 1`] = `
[
{
"component": "",
"path": "/community",
},
{
"component": "",
"path": "/some-page",
},
{
"component": "",
"path": "/docs",
"routes": [
{
"component": "",
"path": "/docs/someDoc",
},
{
"component": "",
"path": "/docs/someOtherDoc",
},
],
},
{
"component": "",
"path": "/",
},
{
"component": "",
"path": "/",
"routes": [
{
"component": "",
"path": "/someDoc",
},
{
"component": "",
"path": "/someOtherDoc",
},
],
},
{
"component": "",
"path": "/",
"routes": [
{
"component": "",
"path": "/subroute",
},
],
},
]
`;
exports[`sortConfig sorts route config given a baseURL 1`] = `
[
{
"component": "",
"path": "/latest/community",
},
{
"component": "",
"path": "/latest/example",
},
{
"component": "",
"path": "/latest/some-page",
},
{
"component": "",
"path": "/latest/docs",
"routes": [
{
"component": "",
"path": "/latest/docs/someDoc",
},
{
"component": "",
"path": "/latest/docs/someOtherDoc",
},
],
},
{
"component": "",
"path": "/latest/",
},
{
"component": "",
"path": "/latest/",
"routes": [
{
"component": "",
"path": "/latest/someDoc",
},
{
"component": "",
"path": "/latest/someOtherDoc",
},
],
},
]
`;

View file

@ -27,103 +27,3 @@ Example valid plugin config:
"
`;
exports[`sortConfig sorts route config correctly 1`] = `
[
{
"component": "",
"path": "/community",
},
{
"component": "",
"path": "/some-page",
},
{
"component": "",
"path": "/docs",
"routes": [
{
"component": "",
"path": "/docs/someDoc",
},
{
"component": "",
"path": "/docs/someOtherDoc",
},
],
},
{
"component": "",
"path": "/",
},
{
"component": "",
"path": "/",
"routes": [
{
"component": "",
"path": "/someDoc",
},
{
"component": "",
"path": "/someOtherDoc",
},
],
},
{
"component": "",
"path": "/",
"routes": [
{
"component": "",
"path": "/subroute",
},
],
},
]
`;
exports[`sortConfig sorts route config given a baseURL 1`] = `
[
{
"component": "",
"path": "/latest/community",
},
{
"component": "",
"path": "/latest/example",
},
{
"component": "",
"path": "/latest/some-page",
},
{
"component": "",
"path": "/latest/docs",
"routes": [
{
"component": "",
"path": "/latest/docs/someDoc",
},
{
"component": "",
"path": "/latest/docs/someOtherDoc",
},
],
},
{
"component": "",
"path": "/latest",
"routes": [
{
"component": "",
"path": "/latest/someDoc",
},
{
"component": "",
"path": "/latest/someOtherDoc",
},
],
},
]
`;

View file

@ -0,0 +1,139 @@
/**
* 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 {loadPlugins, sortConfig} from '..';
import type {RouteConfig} from '@docusaurus/types';
describe('loadPlugins', () => {
it('loads plugins', async () => {
const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin');
await expect(
loadPlugins({
pluginConfigs: [
() => ({
name: 'test1',
prop: 'a',
async loadContent() {
// Testing that plugin lifecycle is bound to the plugin instance
return this.prop;
},
async contentLoaded({content, actions}) {
actions.setGlobalData({content, prop: this.prop});
},
}),
() => ({
name: 'test2',
configureWebpack() {
return {};
},
}),
],
context: {
siteDir,
generatedFilesDir: path.join(siteDir, '.docusaurus'),
outDir: path.join(siteDir, 'build'),
// @ts-expect-error: good enough
siteConfig: {
baseUrl: '/',
trailingSlash: true,
themeConfig: {},
},
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
},
}),
).resolves.toMatchSnapshot();
});
});
describe('sortConfig', () => {
it('sorts route config correctly', () => {
const routes: RouteConfig[] = [
{
path: '/',
component: '',
routes: [
{path: '/someDoc', component: ''},
{path: '/someOtherDoc', component: ''},
],
},
{
path: '/',
component: '',
},
{
path: '/',
component: '',
routes: [{path: '/subroute', component: ''}],
},
{
path: '/docs',
component: '',
routes: [
{path: '/docs/someDoc', component: ''},
{path: '/docs/someOtherDoc', component: ''},
],
},
{
path: '/community',
component: '',
},
{
path: '/some-page',
component: '',
},
];
sortConfig(routes);
expect(routes).toMatchSnapshot();
});
it('sorts route config given a baseURL', () => {
const baseURL = '/latest/';
const routes: RouteConfig[] = [
{
path: baseURL,
component: '',
routes: [
{path: `${baseURL}someDoc`, component: ''},
{path: `${baseURL}someOtherDoc`, component: ''},
],
},
{
path: `${baseURL}example`,
component: '',
},
{
path: `${baseURL}docs`,
component: '',
routes: [
{path: `${baseURL}docs/someDoc`, component: ''},
{path: `${baseURL}docs/someOtherDoc`, component: ''},
],
},
{
path: `${baseURL}community`,
component: '',
},
{
path: `${baseURL}some-page`,
component: '',
},
{
path: `${baseURL}`,
component: '',
},
];
sortConfig(routes, baseURL);
expect(routes).toMatchSnapshot();
});
});

View file

@ -13,8 +13,6 @@ import {
type LoadContextOptions,
} from '../../index';
import initPlugins from '../init';
import {sortConfig} from '../index';
import type {RouteConfig} from '@docusaurus/types';
describe('initPlugins', () => {
async function loadSite(options: LoadContextOptions = {}) {
@ -53,85 +51,3 @@ describe('initPlugins', () => {
).rejects.toThrowErrorMatchingSnapshot();
});
});
describe('sortConfig', () => {
it('sorts route config correctly', () => {
const routes: RouteConfig[] = [
{
path: '/',
component: '',
routes: [
{path: '/someDoc', component: ''},
{path: '/someOtherDoc', component: ''},
],
},
{
path: '/',
component: '',
},
{
path: '/',
component: '',
routes: [{path: '/subroute', component: ''}],
},
{
path: '/docs',
component: '',
routes: [
{path: '/docs/someDoc', component: ''},
{path: '/docs/someOtherDoc', component: ''},
],
},
{
path: '/community',
component: '',
},
{
path: '/some-page',
component: '',
},
];
sortConfig(routes);
expect(routes).toMatchSnapshot();
});
it('sorts route config given a baseURL', () => {
const baseURL = '/latest';
const routes: RouteConfig[] = [
{
path: baseURL,
component: '',
routes: [
{path: `${baseURL}/someDoc`, component: ''},
{path: `${baseURL}/someOtherDoc`, component: ''},
],
},
{
path: `${baseURL}/example`,
component: '',
},
{
path: `${baseURL}/docs`,
component: '',
routes: [
{path: `${baseURL}/docs/someDoc`, component: ''},
{path: `${baseURL}/docs/someOtherDoc`, component: ''},
],
},
{
path: `${baseURL}/community`,
component: '',
},
{
path: `${baseURL}/some-page`,
component: '',
},
];
sortConfig(routes, baseURL);
expect(routes).toMatchSnapshot();
});
});

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {generate, DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import {generate} from '@docusaurus/utils';
import fs from 'fs-extra';
import path from 'path';
import type {
@ -125,7 +125,7 @@ export async function loadPlugins({
.groupBy((item) => item.name)
.mapValues((nameItems) =>
_.chain(nameItems)
.groupBy((item) => item.options.id ?? DEFAULT_PLUGIN_ID)
.groupBy((item) => item.options.id)
.mapValues((idItems) => idItems[0]!.content)
.value(),
)
@ -143,7 +143,7 @@ export async function loadPlugins({
return;
}
const pluginId = plugin.options.id ?? DEFAULT_PLUGIN_ID;
const pluginId = plugin.options.id;
// plugins data files are namespaced by pluginName/pluginId
const dataDirRoot = path.join(context.generatedFilesDir, plugin.name);

View file

@ -7,9 +7,9 @@
module.exports = function preset(context, opts = {}) {
return {
plugins: [
['@docusaurus/plugin-content-pages', opts.pages],
['@docusaurus/plugin-sitemap', opts.sitemap],
themes: [
['@docusaurus/theme-live-codeblock', opts.codeblock],
['@docusaurus/theme-algolia', opts.algolia],
],
};
};

View file

@ -29,18 +29,19 @@ exports[`loadPresets array form composite 1`] = `
"@docusaurus/plugin-content-blog",
undefined,
],
[
"@docusaurus/plugin-content-pages",
{
"path": "../",
},
],
"themes": [
[
"@docusaurus/plugin-sitemap",
"@docusaurus/theme-live-codeblock",
undefined,
],
[
"@docusaurus/theme-algolia",
{
"trackingID": "foo",
},
],
],
"themes": [],
}
`;
@ -75,16 +76,17 @@ exports[`loadPresets mixed form 1`] = `
"@docusaurus/plugin-content-blog",
undefined,
],
],
"themes": [
[
"@docusaurus/plugin-content-pages",
"@docusaurus/theme-live-codeblock",
undefined,
],
[
"@docusaurus/plugin-sitemap",
"@docusaurus/theme-algolia",
undefined,
],
],
"themes": [],
}
`;
@ -101,20 +103,20 @@ exports[`loadPresets mixed form with themes 1`] = `
"@docusaurus/plugin-content-blog",
undefined,
],
[
"@docusaurus/plugin-content-pages",
undefined,
],
[
"@docusaurus/plugin-sitemap",
undefined,
],
[
"@docusaurus/plugin-test",
undefined,
],
],
"themes": [
[
"@docusaurus/theme-live-codeblock",
undefined,
],
[
"@docusaurus/theme-algolia",
undefined,
],
[
"@docusaurus/theme-classic",
undefined,
@ -150,15 +152,16 @@ exports[`loadPresets string form composite 1`] = `
"@docusaurus/plugin-content-blog",
undefined,
],
],
"themes": [
[
"@docusaurus/plugin-content-pages",
"@docusaurus/theme-live-codeblock",
undefined,
],
[
"@docusaurus/plugin-sitemap",
"@docusaurus/theme-algolia",
undefined,
],
],
"themes": [],
}
`;

View file

@ -31,7 +31,7 @@ describe('loadPresets', () => {
const context = {
siteConfigPath: __dirname,
siteConfig: {
presets: [path.join(__dirname, '__fixtures__/preset-bar.js')],
presets: [path.join(__dirname, '__fixtures__/preset-plugins.js')],
},
} as LoadContext;
const presets = await loadPresets(context);
@ -43,8 +43,8 @@ describe('loadPresets', () => {
siteConfigPath: __dirname,
siteConfig: {
presets: [
path.join(__dirname, '__fixtures__/preset-bar.js'),
path.join(__dirname, '__fixtures__/preset-foo.js'),
path.join(__dirname, '__fixtures__/preset-plugins.js'),
path.join(__dirname, '__fixtures__/preset-themes.js'),
],
},
} as LoadContext;
@ -56,7 +56,7 @@ describe('loadPresets', () => {
const context = {
siteConfigPath: __dirname,
siteConfig: {
presets: [[path.join(__dirname, '__fixtures__/preset-bar.js')]],
presets: [[path.join(__dirname, '__fixtures__/preset-plugins.js')]],
},
} as Partial<LoadContext>;
const presets = await loadPresets(context);
@ -69,7 +69,7 @@ describe('loadPresets', () => {
siteConfig: {
presets: [
[
path.join(__dirname, '__fixtures__/preset-bar.js'),
path.join(__dirname, '__fixtures__/preset-plugins.js'),
{docs: {path: '../'}},
],
],
@ -85,12 +85,12 @@ describe('loadPresets', () => {
siteConfig: {
presets: [
[
path.join(__dirname, '__fixtures__/preset-bar.js'),
path.join(__dirname, '__fixtures__/preset-plugins.js'),
{docs: {path: '../'}},
],
[
path.join(__dirname, '__fixtures__/preset-foo.js'),
{pages: {path: '../'}},
path.join(__dirname, '__fixtures__/preset-themes.js'),
{algolia: {trackingID: 'foo'}},
],
],
},
@ -105,10 +105,10 @@ describe('loadPresets', () => {
siteConfig: {
presets: [
[
path.join(__dirname, '__fixtures__/preset-bar.js'),
path.join(__dirname, '__fixtures__/preset-plugins.js'),
{docs: {path: '../'}},
],
path.join(__dirname, '__fixtures__/preset-foo.js'),
path.join(__dirname, '__fixtures__/preset-themes.js'),
],
},
} as LoadContext;
@ -122,11 +122,11 @@ describe('loadPresets', () => {
siteConfig: {
presets: [
[
path.join(__dirname, '__fixtures__/preset-bar.js'),
path.join(__dirname, '__fixtures__/preset-plugins.js'),
{docs: {path: '../'}},
],
path.join(__dirname, '__fixtures__/preset-foo.js'),
path.join(__dirname, '__fixtures__/preset-qux.js'),
path.join(__dirname, '__fixtures__/preset-themes.js'),
path.join(__dirname, '__fixtures__/preset-mixed.js'),
],
},
} as LoadContext;

View file

@ -30,7 +30,7 @@ function indent(str: string) {
return `${spaces}${str.replace(/\n/g, `\n${spaces}`)}`;
}
const createRouteCodeString = ({
function createRouteCodeString({
routePath,
routeHash,
exact,
@ -42,7 +42,7 @@ const createRouteCodeString = ({
exact?: boolean;
subroutesCodeStrings?: string[];
props: {[propName: string]: unknown};
}) => {
}) {
const parts = [
`path: '${routePath}'`,
`component: ComponentCreator('${routePath}','${routeHash}')`,
@ -61,17 +61,30 @@ ${indent(removeSuffix(subroutesCodeStrings.join(',\n'), ',\n'))}
}
Object.entries(props).forEach(([propName, propValue]) => {
// Figure out how to "unquote" JS attributes that don't need to be quoted
// Is this lib reliable? https://github.com/armanozak/should-quote
const shouldQuote = true; // TODO
const key = shouldQuote ? `'${propName}'` : propName;
// Inspired by https://github.com/armanozak/should-quote/blob/main/packages/should-quote/src/lib/should-quote.ts
const shouldQuote = ((key: string) => {
// Pre-sanitation to prevent injection
if (/[.,;:}/\s]/.test(key)) {
return true;
}
try {
// If this key can be used in an expression like ({a:0}).a
// eslint-disable-next-line no-eval
eval(`({${key}:0}).${key}`);
return false;
} catch {
return true;
}
})(propName);
// Escape quotes as well
const key = shouldQuote ? JSON.stringify(propName) : propName;
parts.push(`${key}: ${JSON.stringify(propValue)}`);
});
return `{
${indent(parts.join(',\n'))}
}`;
};
}
const NotFoundRouteCode = `{
path: '*',

View file

@ -7,7 +7,51 @@
import path from 'path';
import fs from 'fs-extra';
import themeAlias from '../alias';
import themeAlias, {sortAliases} from '../alias';
describe('sortAliases', () => {
// https://github.com/facebook/docusaurus/issues/6878
// Not sure if the risk actually happens, but still made tests to ensure that
// behavior is consistent
it('sorts reliably', () => {
expect(
Object.values(
sortAliases({
'@a/b': 'b',
'@a/b/c': 'c',
'@a/b/c/d': 'd',
}),
),
).toEqual(['d', 'c', 'b']);
expect(
Object.values(
sortAliases({
'@a/b': 'b',
'@a/b/c/d': 'd',
'@a/b/c': 'c',
}),
),
).toEqual(['d', 'c', 'b']);
expect(
Object.values(
sortAliases({
'@a/b/c/d': 'd',
'@a/b/c': 'c',
'@a/b': 'b',
}),
),
).toEqual(['d', 'c', 'b']);
expect(
Object.values(
sortAliases({
'@a/b/c': 'c',
'@a/b': 'b',
'@a/b/c/d': 'd',
}),
),
).toEqual(['d', 'c', 'b']);
});
});
describe('themeAlias', () => {
it('valid themePath 1 with components', async () => {

View file

@ -10,7 +10,7 @@ import path from 'path';
import {THEME_PATH} from '@docusaurus/utils';
import themeAlias, {sortAliases} from './alias';
const ThemeFallbackDir = path.resolve(__dirname, '../../client/theme-fallback');
const ThemeFallbackDir = path.join(__dirname, '../../client/theme-fallback');
export async function loadThemeAliases(
themePaths: string[],

View file

@ -7,10 +7,9 @@
import {jest} from '@jest/globals';
import {
ensureTranslationFileContent,
writeTranslationFileContent,
writePluginTranslations,
readTranslationFileContent,
writeCodeTranslations,
readCodeTranslationFileContent,
type WriteTranslationsOptions,
localizePluginTranslationFile,
getPluginsDefaultCodeTranslationMessages,
@ -35,129 +34,93 @@ async function createTmpSiteDir() {
async function createTmpTranslationFile(
content: TranslationFileContent | null,
) {
const filePath = await tmp.tmpName({
prefix: 'jest-createTmpTranslationFile',
postfix: '.json',
});
const siteDir = await createTmpSiteDir();
const filePath = path.join(siteDir, 'i18n/en/code.json');
// null means we don't want a file, just a filename
if (content !== null) {
await fs.writeFile(filePath, JSON.stringify(content, null, 2));
await fs.outputFile(filePath, JSON.stringify(content, null, 2));
}
return {
filePath,
readFile: async () => JSON.parse(await fs.readFile(filePath, 'utf8')),
siteDir,
readFile() {
return fs.readJSON(filePath);
},
};
}
describe('ensureTranslationFileContent', () => {
it('passes 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'},
});
describe('writeCodeTranslations', () => {
const consoleInfoMock = jest
.spyOn(console, 'info')
.mockImplementation(() => {});
beforeEach(() => {
consoleInfoMock.mockClear();
});
it('fails 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', () => {
it('creates new translation file', async () => {
const {filePath, readFile} = await createTmpTranslationFile(null);
await writeTranslationFileContent({
filePath,
content: {
const {siteDir, readFile} = await createTmpTranslationFile(null);
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message'},
key2: {message: 'key2 message'},
key3: {message: 'key3 message'},
},
});
{},
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'key1 message'},
key2: {message: 'key2 message'},
key3: {message: 'key3 message'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/3.* translations will be written/),
);
});
it('creates new translation file with prefix', async () => {
const {filePath, readFile} = await createTmpTranslationFile(null);
await writeTranslationFileContent({
filePath,
content: {
const {siteDir, readFile} = await createTmpTranslationFile(null);
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message'},
key2: {message: 'key2 message'},
key3: {message: 'key3 message'},
},
options: {
{
messagePrefix: 'PREFIX ',
},
});
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'PREFIX key1 message'},
key2: {message: 'PREFIX key2 message'},
key3: {message: 'PREFIX key3 message'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/3.* translations will be written/),
);
});
it('appends missing translations', async () => {
const {filePath, readFile} = await createTmpTranslationFile({
const {siteDir, readFile} = await createTmpTranslationFile({
key1: {message: 'key1 message'},
key2: {message: 'key2 message'},
key3: {message: 'key3 message'},
});
await writeTranslationFileContent({
filePath,
content: {
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message new'},
key2: {message: 'key2 message new'},
key3: {message: 'key3 message new'},
key4: {message: 'key4 message new'},
},
});
{},
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'key1 message'},
@ -165,111 +128,139 @@ describe('writeTranslationFileContent', () => {
key3: {message: 'key3 message'},
key4: {message: 'key4 message new'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/4.* translations will be written/),
);
});
it('appends missing translations with prefix', async () => {
const {filePath, readFile} = await createTmpTranslationFile({
it('appends missing.* translations with prefix', async () => {
const {siteDir, readFile} = await createTmpTranslationFile({
key1: {message: 'key1 message'},
});
await writeTranslationFileContent({
filePath,
content: {
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message new'},
key2: {message: 'key2 message new'},
},
options: {
{
messagePrefix: 'PREFIX ',
},
});
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'key1 message'},
key2: {message: 'PREFIX key2 message new'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/2.* translations will be written/),
);
});
it('overrides missing translations', async () => {
const {filePath, readFile} = await createTmpTranslationFile({
const {siteDir, readFile} = await createTmpTranslationFile({
key1: {message: 'key1 message'},
});
await writeTranslationFileContent({
filePath,
content: {
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message new'},
key2: {message: 'key2 message new'},
},
options: {
{
override: true,
},
});
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'key1 message new'},
key2: {message: 'key2 message new'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/2.* translations will be written/),
);
});
it('overrides missing translations with prefix', async () => {
const {filePath, readFile} = await createTmpTranslationFile({
const {siteDir, readFile} = await createTmpTranslationFile({
key1: {message: 'key1 message'},
});
await writeTranslationFileContent({
filePath,
content: {
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message new'},
key2: {message: 'key2 message new'},
},
options: {
{
override: true,
messagePrefix: 'PREFIX ',
},
});
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'PREFIX key1 message new'},
key2: {message: 'PREFIX key2 message new'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/2.* translations will be written/),
);
});
it('always overrides message description', async () => {
const {filePath, readFile} = await createTmpTranslationFile({
const {siteDir, 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: {
await writeCodeTranslations(
{siteDir, locale: 'en'},
{
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'},
},
});
{},
);
await expect(readFile()).resolves.toEqual({
key1: {message: 'key1 message', description: undefined},
key2: {message: 'key2 message', description: 'key2 desc new'},
key3: {message: 'key3 message', description: 'key3 desc new'},
});
expect(consoleInfoMock).toBeCalledWith(
expect.stringMatching(/3.* translations will be written/),
);
});
it('does not create empty translation files', async () => {
const {siteDir, readFile} = await createTmpTranslationFile(null);
await writeCodeTranslations({siteDir, locale: 'en'}, {}, {});
await expect(readFile()).rejects.toThrowError(
/ENOENT: no such file or directory, open /,
);
expect(consoleInfoMock).toBeCalledTimes(0);
});
it('throws for invalid content', async () => {
const {filePath} = await createTmpTranslationFile(
const {siteDir} = await createTmpTranslationFile(
// @ts-expect-error: bad content on purpose
{bad: 'content'},
);
await expect(
writeTranslationFileContent({
filePath,
content: {
await expect(() =>
writeCodeTranslations(
{siteDir, locale: 'en'},
{
key1: {message: 'key1 message'},
},
}),
{},
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"bad\\" must be of type object"`,
);
@ -308,7 +299,7 @@ describe('writePluginTranslations', () => {
},
});
await expect(readTranslationFileContent(filePath)).resolves.toEqual({
await expect(fs.readJSON(filePath)).resolves.toEqual({
key1: {message: 'key1 message'},
key2: {message: 'key2 message'},
key3: {message: 'key3 message'},
@ -348,14 +339,12 @@ describe('writePluginTranslations', () => {
});
}
await expect(readTranslationFileContent(filePath)).resolves.toBeUndefined();
await doWritePluginTranslations({
key1: {message: 'key1 message', description: 'key1 desc'},
key2: {message: 'key2 message', description: 'key2 desc'},
key3: {message: 'key3 message', description: 'key3 desc'},
});
await expect(readTranslationFileContent(filePath)).resolves.toEqual({
await expect(fs.readJSON(filePath)).resolves.toEqual({
key1: {message: 'key1 message', description: 'key1 desc'},
key2: {message: 'key2 message', description: 'key2 desc'},
key3: {message: 'key3 message', description: 'key3 desc'},
@ -368,7 +357,7 @@ describe('writePluginTranslations', () => {
},
{messagePrefix: 'PREFIX '},
);
await expect(readTranslationFileContent(filePath)).resolves.toEqual({
await expect(fs.readJSON(filePath)).resolves.toEqual({
key1: {message: 'key1 message', description: 'key1 desc'},
key2: {message: 'key2 message', description: 'key2 desc'},
key3: {message: 'key3 message', description: undefined},
@ -384,13 +373,39 @@ describe('writePluginTranslations', () => {
},
{messagePrefix: 'PREFIX ', override: true},
);
await expect(readTranslationFileContent(filePath)).resolves.toEqual({
await expect(fs.readJSON(filePath)).resolves.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'},
});
});
it('throws with explicit extension', async () => {
const siteDir = await createTmpSiteDir();
await expect(() =>
writePluginTranslations({
siteDir,
locale: 'fr',
translationFile: {
path: 'my/translation/file.json',
content: {},
},
plugin: {
name: 'my-plugin-name',
options: {
id: 'my-plugin-id',
},
},
options: {},
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Translation file path at \\"my/translation/file.json\\" does not need to end with \\".json\\", we add the extension automatically."`,
);
});
});
describe('localizePluginTranslationFile', () => {
@ -420,22 +435,22 @@ describe('localizePluginTranslationFile', () => {
expect(localizedTranslationFile).toEqual(translationFile);
});
it('does not localize if localized file does not exist 2', async () => {
it('normalizes partially localized translation files', async () => {
const siteDir = await createTmpSiteDir();
await writeTranslationFileContent({
filePath: path.join(
await fs.outputJSON(
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',
@ -472,6 +487,68 @@ describe('localizePluginTranslationFile', () => {
});
});
describe('readCodeTranslationFileContent', () => {
async function testReadTranslation(val: TranslationFileContent) {
const {siteDir} = await createTmpTranslationFile(val);
return readCodeTranslationFileContent({siteDir, locale: 'en'});
}
it("returns undefined if file does't exist", async () => {
await expect(
readCodeTranslationFileContent({siteDir: 'foo', locale: 'en'}),
).resolves.toBeUndefined();
});
it('passes valid translation file content', async () => {
await expect(testReadTranslation({})).resolves.toEqual({});
await expect(testReadTranslation({key1: {message: ''}})).resolves.toEqual({
key1: {message: ''},
});
await expect(
testReadTranslation({key1: {message: 'abc', description: 'desc'}}),
).resolves.toEqual({key1: {message: 'abc', description: 'desc'}});
await expect(
testReadTranslation({
key1: {message: 'abc', description: 'desc'},
key2: {message: 'def', description: 'desc'},
}),
).resolves.toEqual({
key1: {message: 'abc', description: 'desc'},
key2: {message: 'def', description: 'desc'},
});
});
it('fails for invalid translation file content', async () => {
await expect(() =>
testReadTranslation('HEY'),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"value\\" must be of type object"`,
);
await expect(() =>
testReadTranslation(42),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"value\\" must be of type object"`,
);
await expect(() =>
testReadTranslation({key: {description: 'no message'}}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"key.message\\" is required"`,
);
await expect(() =>
testReadTranslation({key: {message: 42}}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"key.message\\" must be a string"`,
);
await expect(() =>
testReadTranslation({
key: {message: 'Message', description: 42},
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"key.description\\" must be a string"`,
);
});
});
describe('getPluginsDefaultCodeTranslationMessages', () => {
function createTestPlugin(
fn: InitializedPlugin['getDefaultCodeTranslationMessages'],
@ -559,9 +636,11 @@ describe('getPluginsDefaultCodeTranslationMessages', () => {
});
describe('applyDefaultCodeTranslations', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const consoleWarnMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
beforeEach(() => {
consoleSpy.mockClear();
consoleWarnMock.mockClear();
});
it('works for no code and message', () => {
@ -571,7 +650,7 @@ describe('applyDefaultCodeTranslations', () => {
defaultCodeMessages: {},
}),
).toEqual({});
expect(consoleSpy).toHaveBeenCalledTimes(0);
expect(consoleWarnMock).toHaveBeenCalledTimes(0);
});
it('works for code and message', () => {
@ -593,7 +672,7 @@ describe('applyDefaultCodeTranslations', () => {
description: 'description',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(0);
expect(consoleWarnMock).toHaveBeenCalledTimes(0);
});
it('works for code and message mismatch', () => {
@ -615,8 +694,8 @@ describe('applyDefaultCodeTranslations', () => {
description: 'description',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toMatch(/unknownId/);
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
expect(consoleWarnMock.mock.calls[0][0]).toMatch(/unknownId/);
});
it('works for realistic scenario', () => {
@ -657,8 +736,8 @@ describe('applyDefaultCodeTranslations', () => {
description: 'description 3',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toMatch(/idUnknown1/);
expect(consoleSpy.mock.calls[0][0]).toMatch(/idUnknown2/);
expect(consoleWarnMock).toHaveBeenCalledTimes(1);
expect(consoleWarnMock.mock.calls[0][0]).toMatch(/idUnknown1/);
expect(consoleWarnMock.mock.calls[0][0]).toMatch(/idUnknown2/);
});
});

View file

@ -232,6 +232,7 @@ export default function MyComponent<T>(props: ComponentProps<T>) {
return (
<div>
<input text={translate({id: 'codeId',message: 'code message',description: 'code description'}) as string}/>
<input text={translate({message: 'code message 2',description: 'code description 2'}) as string}/>
</div>
);
}
@ -247,6 +248,10 @@ export default function MyComponent<T>(props: ComponentProps<T>) {
sourceCodeFilePath,
translations: {
codeId: {message: 'code message', description: 'code description'},
'code message 2': {
message: 'code message 2',
description: 'code description 2',
},
},
warnings: [],
});
@ -636,6 +641,26 @@ export default function MyComponent() {
}
`,
);
const plugin1File4 = path.join(plugin1Dir, 'src/theme/file4.jsx');
// Contains some invalid translations...
await fs.outputFile(
plugin1File4,
`
import {translate} from '@docusaurus/Translate';
export default function MyComponent() {
return (
<div>
<input text={translate({id: index})}/>
</div>
);
}
`,
);
const consoleWarnMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const plugin1 = createTestPlugin(plugin1Dir);
const plugin2Dir = await createTmpDir();
@ -664,7 +689,11 @@ export default function MyComponent(props: Props) {
);
const plugin2 = createTestPlugin(plugin2Dir);
const plugins = [plugin1, plugin2];
const plugins = [
plugin1,
plugin2,
{name: 'dummy', options: {}, version: {type: 'synthetic'}} as const,
];
const translations = await extractSiteSourceCodeTranslations(
siteDir,
plugins,
@ -692,5 +721,8 @@ export default function MyComponent(props: Props) {
message: 'plugin2 message 2',
},
});
expect(consoleWarnMock.mock.calls[0][0]).toMatch(
/.*\[WARNING\].* Translation extraction warnings for file .*src.theme.file4\.jsx.*\n.*- translate\(\) first arg should be a statically evaluable object\./,
);
});
});

View file

@ -38,7 +38,7 @@ const TranslationFileContentSchema = Joi.object<TranslationFileContent>()
)
.required();
export function ensureTranslationFileContent(
function ensureTranslationFileContent(
content: unknown,
): asserts content is TranslationFileContent {
Joi.attempt(content, TranslationFileContentSchema, {
@ -48,7 +48,7 @@ export function ensureTranslationFileContent(
});
}
export async function readTranslationFileContent(
async function readTranslationFileContent(
filePath: string,
): Promise<TranslationFileContent | undefined> {
if (await fs.pathExists(filePath)) {
@ -97,7 +97,7 @@ function mergeTranslationFileContent({
return result;
}
export async function writeTranslationFileContent({
async function writeTranslationFileContent({
filePath,
content: newContent,
options = {},
@ -139,7 +139,7 @@ Maybe you should remove them? ${unknownKeys}`;
}
// should we make this configurable?
export function getTranslationsDirPath(context: TranslationContext): string {
function getTranslationsDirPath(context: TranslationContext): string {
return path.resolve(path.join(context.siteDir, `i18n`));
}
export function getTranslationsLocaleDirPath(
@ -148,9 +148,7 @@ export function getTranslationsLocaleDirPath(
return path.join(getTranslationsDirPath(context), context.locale);
}
export function getCodeTranslationsFilePath(
context: TranslationContext,
): string {
function getCodeTranslationsFilePath(context: TranslationContext): string {
return path.join(getTranslationsLocaleDirPath(context), 'code.json');
}

View file

@ -40,7 +40,7 @@ function testStylelintRule(config, tests) {
}
const fixedOutput = await stylelint.lint({...options, fix: true});
const fixedCode = getOutputCss(fixedOutput);
expect(fixedCode).toBe(testCase.fixed);
expect(fixedCode).toBe(testCase.code);
});
});
});
@ -113,22 +113,63 @@ testStylelintRule(
},
{
ruleName,
fix: false,
fix: true,
accept: [
{
code: `
/**
* Copyright
*/
.foo {}`,
},
{
code: `/**
* Copyright
*/
.foo {}`,
},
{
code: `/**
* Copyright
*/
.foo {}`,
},
],
reject: [
{
code: `.foo {}`,
fixed: `/**
* Copyright
*/
.foo {}`,
message: messages.rejected,
line: 1,
column: 1,
},
{
code: `
.foo {}`,
fixed: `/**
* Copyright
*/
.foo {}`,
message: messages.rejected,
line: 1,
column: 1,
},
{
code: `/**
* Copyright
*/
.foo {}`,
fixed: `/**
* Copyright
*/
/**
* copyright
* Copyright
*/
.foo {}`,
@ -137,7 +178,15 @@ testStylelintRule(
column: 1,
},
{
code: `
code: `/**
* Copyleft
*/
.foo {}`,
fixed: `/**
* Copyright
*/
/**
* Copyleft
*/
@ -148,7 +197,18 @@ testStylelintRule(
column: 1,
},
{
code: `
code: `/**
* Copyleft
*/
/**
* Copyright
*/
.foo {}`,
fixed: `/**
* Copyright
*/
/**
* Copyleft
*/

View file

@ -15,7 +15,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
const plugin = stylelint.createPlugin(
ruleName,
(primaryOption, secondaryOption, context) => (root, result) => {
const validOptions = stylelint.utils.validateOptions(
stylelint.utils.validateOptions(
result,
ruleName,
{
@ -28,10 +28,6 @@ const plugin = stylelint.createPlugin(
},
);
if (!validOptions) {
return;
}
if (
root.first &&
root.first.type === 'comment' &&