mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 15:47:23 +02:00
test: improve test coverage; multiple internal refactors (#6912)
This commit is contained in:
parent
12a7305238
commit
ad88f5cc87
78 changed files with 1613 additions and 1149 deletions
|
@ -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) {
|
||||
|
|
1
packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/outside/doc1.md
generated
Normal file
1
packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/outside/doc1.md
generated
Normal file
|
@ -0,0 +1 @@
|
|||
[link](../docs/doc1.md)
|
|
@ -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!"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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!`,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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) =>
|
|
@ -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": "انتقل إلى المحتوى الرئيسي",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "স্কিপ করে মূল কন্টেন্ট এ যান",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "پرش به مطلب اصلی",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "דלג לתוכן הראשי",
|
||||
|
|
|
@ -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": "मुख्य कंटेंट तक स्किप करें",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "メインコンテンツまでスキップ",
|
||||
|
|
|
@ -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": "본문으로 건너뛰기",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Перейти к основному содержимому",
|
||||
|
|
|
@ -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": "Пређи на главни садржај",
|
||||
|
|
|
@ -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ç",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "跳到主要内容",
|
||||
|
|
|
@ -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": "跳至主要内容",
|
||||
|
|
|
@ -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",
|
||||
|
|
5
packages/docusaurus-theme-translations/src/__tests__/__fixtures__/theme/index.js
generated
Normal file
5
packages/docusaurus-theme-translations/src/__tests__/__fixtures__/theme/index.js
generated
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Translate from '@docusaurus/Translate';
|
||||
|
||||
export default function Foo() {
|
||||
return <Translate>{index}</Translate>;
|
||||
}
|
|
@ -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>
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
100
packages/docusaurus-theme-translations/src/utils.ts
Normal file
100
packages/docusaurus-theme-translations/src/utils.ts
Normal 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;
|
||||
}
|
11
packages/docusaurus-theme-translations/tsconfig.build.json
Normal file
11
packages/docusaurus-theme-translations/tsconfig.build.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo",
|
||||
"sourceMap": true,
|
||||
"declarationMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
}
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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>;
|
|
@ -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;
|
247
packages/docusaurus-theme-translations/update.mjs
Normal file
247
packages/docusaurus-theme-translations/update.mjs
Normal 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')}`;
|
2
packages/docusaurus-types/src/index.d.ts
vendored
2
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}`;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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> = {},
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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\\"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}".`);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`;
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
|
139
packages/docusaurus/src/server/plugins/__tests__/index.test.ts
Normal file
139
packages/docusaurus/src/server/plugins/__tests__/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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],
|
||||
],
|
||||
};
|
||||
};
|
|
@ -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": [],
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: '*',
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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[],
|
||||
|
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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\./,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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' &&
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue