Merge branch 'main' into ozaki/showcase

This commit is contained in:
sebastien 2024-05-13 15:34:36 +02:00
commit 99a4c5f9a7
65 changed files with 1842 additions and 524 deletions

View file

@ -15,4 +15,4 @@ jobs:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Dependency Review
uses: actions/dependency-review-action@e58c696e52cac8e62d61cc21fda89565d71505d7 # 4.3.1
uses: actions/dependency-review-action@0c155c5e8556a497adf53f2c18edabf945ed8e70 # 4.3.2

View file

@ -111,10 +111,18 @@ function throwOnConsole(page: Page) {
'Failed to load resource: the server responded with a status of 404 (Not Found)',
// TODO looks like a legit hydration bug to fix
// on /blog/releases/2.4
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs" "/docs?docusaurus-theme=light"',
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs" "/docs?docusaurus-theme=dark"',
// on /blog/releases/3.0
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs" "/docs?docusaurus-data-navbar=false&docusaurus-data-red-border"',
// on /docs/styling-layout
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs" "/docs?docusaurus-data-navbar=false&docusaurus-data-red-border"',
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs/configuration" "/docs/configuration?docusaurus-theme=light"',
'Warning: Prop `%s` did not match. Server: %s Client: %s%s href "/docs/configuration" "/docs/configuration?docusaurus-theme=dark"',
// TODO weird problem related to KaTeX fonts refusing to decode?
// on http://localhost:3000/docs/markdown-features/math-equations
// on /docs/markdown-features/math-equations
'Failed to decode downloaded font: http://localhost:3000/katex/fonts/',
'OTS parsing error: Failed to convert WOFF 2.0 font to SFNT',
];

View file

@ -26,6 +26,13 @@ declare module '@generated/site-metadata' {
export = siteMetadata;
}
declare module '@generated/site-storage' {
import type {SiteStorage} from '@docusaurus/types';
const siteStorage: SiteStorage;
export = siteStorage;
}
declare module '@generated/registry' {
import type {Registry} from '@docusaurus/types';

View file

@ -13,7 +13,7 @@ import {isMatch} from 'picomatch';
import commander from 'commander';
import webpack from 'webpack';
import {loadContext} from '@docusaurus/core/src/server/site';
import {applyConfigureWebpack} from '@docusaurus/core/src/webpack/utils';
import {applyConfigureWebpack} from '@docusaurus/core/src/webpack/configure';
import {sortRoutes} from '@docusaurus/core/src/server/plugins/routeConfig';
import {posixPath} from '@docusaurus/utils';
import {normalizePluginOptions} from '@docusaurus/utils-validation';

View file

@ -10,7 +10,7 @@ import {createRequire} from 'module';
import rtlcss from 'rtlcss';
import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations';
import {getTranslationFiles, translateThemeConfig} from './translations';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {LoadContext, Plugin, SiteStorage} from '@docusaurus/types';
import type {ThemeConfig} from '@docusaurus/theme-common';
import type {Plugin as PostCssPlugin} from 'postcss';
import type {PluginOptions} from '@docusaurus/theme-classic';
@ -23,58 +23,66 @@ const ContextReplacementPlugin = requireFromDocusaurusCore(
'webpack/lib/ContextReplacementPlugin',
) as typeof webpack.ContextReplacementPlugin;
// Need to be inlined to prevent dark mode FOUC
// Make sure the key is the same as the one in `/theme/hooks/useTheme.js`
const ThemeStorageKey = 'theme';
// Support for ?docusaurus-theme=dark
const ThemeQueryStringKey = 'docusaurus-theme';
// Support for ?docusaurus-data-mode=embed&docusaurus-data-myAttr=42
const DataQueryStringPrefixKey = 'docusaurus-data-';
const noFlashColorMode = ({
defaultMode,
respectPrefersColorScheme,
}: ThemeConfig['colorMode']) =>
colorMode: {defaultMode, respectPrefersColorScheme},
siteStorage,
}: {
colorMode: ThemeConfig['colorMode'];
siteStorage: SiteStorage;
}) => {
// Need to be inlined to prevent dark mode FOUC
// Make sure the key is the same as the one in the color mode React context
// Currently defined in: `docusaurus-theme-common/src/contexts/colorMode.tsx`
const themeStorageKey = `theme${siteStorage.namespace}`;
/* language=js */
`(function() {
var defaultMode = '${defaultMode}';
var respectPrefersColorScheme = ${respectPrefersColorScheme};
return `(function() {
var defaultMode = '${defaultMode}';
var respectPrefersColorScheme = ${respectPrefersColorScheme};
function setDataThemeAttribute(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
function getQueryStringTheme() {
try {
return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}')
} catch(e) {}
}
function getStoredTheme() {
try {
return localStorage.getItem('${ThemeStorageKey}');
} catch (err) {}
}
var initialTheme = getQueryStringTheme() || getStoredTheme();
if (initialTheme !== null) {
setDataThemeAttribute(initialTheme);
} else {
if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setDataThemeAttribute('dark');
} else if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
setDataThemeAttribute('light');
} else {
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
function setDataThemeAttribute(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
}
})();`;
function getQueryStringTheme() {
try {
return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}')
} catch (e) {
}
}
function getStoredTheme() {
try {
return window['${siteStorage.type}'].getItem('${themeStorageKey}');
} catch (err) {
}
}
var initialTheme = getQueryStringTheme() || getStoredTheme();
if (initialTheme !== null) {
setDataThemeAttribute(initialTheme);
} else {
if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setDataThemeAttribute('dark');
} else if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
setDataThemeAttribute('light');
} else {
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
}
}
})();`;
};
/* language=js */
const DataAttributeQueryStringInlineJavaScript = `
@ -126,6 +134,7 @@ export default function themeClassic(
): Plugin<undefined> {
const {
i18n: {currentLocale, localeConfigs},
siteStorage,
} = context;
const themeConfig = context.siteConfig.themeConfig as ThemeConfig;
const {
@ -218,7 +227,7 @@ export default function themeClassic(
{
tagName: 'script',
innerHTML: `
${noFlashColorMode(colorMode)}
${noFlashColorMode({colorMode, siteStorage})}
${DataAttributeQueryStringInlineJavaScript}
${announcementBar ? AnnouncementBarInlineJavaScript : ''}
`,

View file

@ -32,7 +32,7 @@ function useCategoryItemsPlural() {
count,
translate(
{
message: '{count} items',
message: '1 item|{count} items',
id: 'theme.docs.DocCard.categoryDescription.plurals',
description:
'The default description for a category card in the generated index about how many items this category includes',

View file

@ -6,12 +6,15 @@
*/
import {useCallback, useRef, useSyncExternalStore} from 'react';
import SiteStorage from '@generated/site-storage';
const StorageTypes = ['localStorage', 'sessionStorage', 'none'] as const;
export type StorageType = (typeof SiteStorage)['type'] | 'none';
export type StorageType = (typeof StorageTypes)[number];
const DefaultStorageType: StorageType = SiteStorage.type;
const DefaultStorageType: StorageType = 'localStorage';
function applyNamespace(storageKey: string): string {
return `${storageKey}${SiteStorage.namespace}`;
}
// window.addEventListener('storage') only works for different windows...
// so for current window we have to dispatch the event manually
@ -134,9 +137,10 @@ Please only call storage APIs in effects and event handlers.`);
* this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036
*/
export function createStorageSlot(
key: string,
keyInput: string,
options?: {persistence?: StorageType},
): StorageSlot {
const key = applyNamespace(keyInput);
if (typeof window === 'undefined') {
return createServerStorageSlot(key);
}

View file

@ -81,7 +81,7 @@
"theme.common.headingLinkTitle___DESCRIPTION": "Title for link to heading",
"theme.common.skipToMainContent": "Skip to main content",
"theme.common.skipToMainContent___DESCRIPTION": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.DocCard.categoryDescription.plurals___DESCRIPTION": "The default description for a category card in the generated index about how many items this category includes",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.home___DESCRIPTION": "The ARIA label for the home page in the breadcrumbs",

View file

@ -0,0 +1,7 @@
{
"theme.IdealImageMessage.404error": "404. Снимката не е намерена",
"theme.IdealImageMessage.error": "Грешка. Щракнете, за да презаредите.",
"theme.IdealImageMessage.load": "Щракнете, за да заредите {sizeMessage}",
"theme.IdealImageMessage.loading": "Зарежда...",
"theme.IdealImageMessage.offline": "Браузърът Ви е извън обхват. Снимката не е заредена."
}

View file

@ -0,0 +1,5 @@
{
"theme.PwaReloadPopup.closeButtonAriaLabel": "Затваряне",
"theme.PwaReloadPopup.info": "Налична е нова версия",
"theme.PwaReloadPopup.refreshButtonText": "Опресняване"
}

View file

@ -0,0 +1,74 @@
{
"theme.AnnouncementBar.closeButtonAriaLabel": "Затваряне",
"theme.BackToTopButton.buttonAriaLabel": "Превъртете обратно най-горе",
"theme.CodeBlock.copied": "Копирано",
"theme.CodeBlock.copy": "Копиране",
"theme.CodeBlock.copyButtonAriaLabel": "Копирайте кода в клипборда",
"theme.CodeBlock.wordWrapToggle": "Включване на пренасянето на думи",
"theme.DocSidebarItem.collapseCategoryAriaLabel": "Свиване на категорията '{label}'",
"theme.DocSidebarItem.expandCategoryAriaLabel": "Разширяване на категорията'{label}'",
"theme.ErrorPageContent.title": "Тази страница се срина.",
"theme.ErrorPageContent.tryAgain": "Опитайте отново",
"theme.NavBar.navAriaLabel": "Основен",
"theme.NotFound.p1": "Не успяхме да намерим това, което търсите.",
"theme.NotFound.p2": "Моля, свържете се със собственика на сайта, който ви е свързал с оригиналния URL адрес, и ги уведомете, че връзката им е повредена.",
"theme.NotFound.title": "Страницата не е намерена",
"theme.TOCCollapsible.toggleButtonLabel": "На тази страница",
"theme.admonition.caution": "Внимание",
"theme.admonition.danger": "Опасност",
"theme.admonition.info": "Информация",
"theme.admonition.note": "Бележка",
"theme.admonition.tip": "Съвет",
"theme.admonition.warning": "Внимание",
"theme.blog.archive.description": "Архив",
"theme.blog.archive.title": "Архив",
"theme.blog.paginator.navAriaLabel": "Навигация в страницата със списък на блогове",
"theme.blog.paginator.newerEntries": "По-нови записи",
"theme.blog.paginator.olderEntries": "По-стари записи",
"theme.blog.post.paginator.navAriaLabel": "Навигация на страница с публикации в блогове",
"theme.blog.post.paginator.newerPost": "По-нова публикация",
"theme.blog.post.paginator.olderPost": "По-стар пост",
"theme.blog.post.plurals": "Една публикация|{count} публикации",
"theme.blog.post.readMore": "Прочетете повче",
"theme.blog.post.readMoreLabel": "Прочетете повече за {title}",
"theme.blog.post.readingTime.plurals": "Една минута четене|{readingTime} минути четене",
"theme.blog.sidebar.navAriaLabel": "Навигация в последните публикации в блога",
"theme.blog.tagTitle": "{nPosts} маркиран с/със \"{tagName}\"",
"theme.colorToggle.ariaLabel": "Превключване между тъмен и светъл режим (В момента {mode})",
"theme.colorToggle.ariaLabel.mode.dark": "тъмен режим",
"theme.colorToggle.ariaLabel.mode.light": "светъл режим",
"theme.common.editThisPage": "Редактирай тази страница",
"theme.common.headingLinkTitle": "Директна връзка към {heading}",
"theme.common.skipToMainContent": "Преминете към основното съдържание",
"theme.docs.DocCard.categoryDescription.plurals": "един предмет|{count} предмета",
"theme.docs.breadcrumbs.home": "Начална страница",
"theme.docs.breadcrumbs.navAriaLabel": "Галета",
"theme.docs.paginator.navAriaLabel": "Страници с документaция",
"theme.docs.paginator.next": "Следваща страница",
"theme.docs.paginator.previous": "Предишна страница",
"theme.docs.sidebar.closeSidebarButtonAriaLabel": "Затваряне на лентата за навигация",
"theme.docs.sidebar.collapseButtonAriaLabel": "Свиване на страничната лента",
"theme.docs.sidebar.collapseButtonTitle": "Свиване на страничната лента",
"theme.docs.sidebar.expandButtonAriaLabel": "Разгъване на страничната лентаr",
"theme.docs.sidebar.expandButtonTitle": "Разгъване на страничната лента",
"theme.docs.sidebar.navAriaLabel": "Странична лента на документацията",
"theme.docs.sidebar.toggleSidebarButtonAriaLabel": "Превключване на навигационната лента",
"theme.docs.tagDocListPageTitle": "{nDocsTagged} с/със \"{tagName}\"",
"theme.docs.tagDocListPageTitle.nDocsTagged": "Един документ маркиран|{count} документи маркирани",
"theme.docs.versionBadge.label": "Версия: {versionLabel}",
"theme.docs.versions.latestVersionLinkLabel": "най-новата версия",
"theme.docs.versions.latestVersionSuggestionLabel": "За актуална документация вижте {latestVersionLink} ({versionLabel}).",
"theme.docs.versions.unmaintainedVersionLabel": "Това е документация за {siteTitle} {versionLabel}, който вече не се поддържа активно.",
"theme.docs.versions.unreleasedVersionLabel": "Това е непубликувана документация за версия {versionLabel} на {siteTitle}.",
"theme.lastUpdated.atDate": " на {date}",
"theme.lastUpdated.byUser": " от {user}",
"theme.lastUpdated.lastUpdatedAtBy": "Последно обновено{atDate}{byUser}",
"theme.navbar.mobileLanguageDropdown.label": "Езици",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Назад към главното меню",
"theme.navbar.mobileVersionsDropdown.label": "Версии",
"theme.tags.tagsListLabel": "Етикети:",
"theme.tags.tagsPageLink": "Вижте всички етикети",
"theme.tags.tagsPageTitle": "Етикети",
"theme.unlistedContent.message": "Тази страница е скрита. Търсачките няма да я индексират и само потребители с директна връзка имат достъп до него.",
"theme.unlistedContent.title": "Скрита страница"
}

View file

@ -0,0 +1,4 @@
{
"theme.Playground.liveEditor": "Live Editor",
"theme.Playground.result": "Result"
}

View file

@ -0,0 +1,35 @@
{
"theme.SearchBar.label": "Търсене",
"theme.SearchBar.seeAll": "Вижте всички {count} резултата",
"theme.SearchModal.errorScreen.helpText": "Може да проверите вашата мрежова връзка.",
"theme.SearchModal.errorScreen.titleText": "Не може да се извлекат резултати",
"theme.SearchModal.footer.closeKeyAriaLabel": "Клавиш Escape",
"theme.SearchModal.footer.closeText": "за затваряне",
"theme.SearchModal.footer.navigateDownKeyAriaLabel": "Стрелка надолу",
"theme.SearchModal.footer.navigateText": "за навигация",
"theme.SearchModal.footer.navigateUpKeyAriaLabel": "Стрелка нагоре",
"theme.SearchModal.footer.searchByText": "Търсене от",
"theme.SearchModal.footer.selectKeyAriaLabel": "Клавиш Enter ",
"theme.SearchModal.footer.selectText": "за избиране",
"theme.SearchModal.noResultsScreen.noResultsText": "Няма резултати за",
"theme.SearchModal.noResultsScreen.reportMissingResultsLinkText": "Информирай ни.",
"theme.SearchModal.noResultsScreen.reportMissingResultsText": "Вярвате, че тази заявка трябва да върне резултати?",
"theme.SearchModal.noResultsScreen.suggestedQueryText": "Опитайте да потърсите",
"theme.SearchModal.placeholder": "Търсене в документацията",
"theme.SearchModal.searchBox.cancelButtonText": "Отказ",
"theme.SearchModal.searchBox.resetButtonTitle": "Изчистване на заявката",
"theme.SearchModal.startScreen.favoriteSearchesTitle": "Любими",
"theme.SearchModal.startScreen.noRecentSearchesText": "Няма скорошни търсения",
"theme.SearchModal.startScreen.recentSearchesTitle": "Скорошни",
"theme.SearchModal.startScreen.removeFavoriteSearchButtonTitle": "Премахване на това търсене от любимите",
"theme.SearchModal.startScreen.removeRecentSearchButtonTitle": "Премахване това търсене от историята",
"theme.SearchModal.startScreen.saveRecentSearchButtonTitle": "Запазване това търсене",
"theme.SearchPage.algoliaLabel": "Търсене от Algolia",
"theme.SearchPage.documentsFound.plurals": "Намерен е един документ|{count} намерени документи",
"theme.SearchPage.emptyResultsTitle": "Потърсете в документацията",
"theme.SearchPage.existingResultsTitle": "Резултати от търсенето за \"{query}\"",
"theme.SearchPage.fetchingNewResults": "звличане на нови резултати...",
"theme.SearchPage.inputLabel": "Търсене",
"theme.SearchPage.inputPlaceholder": "Въведете вашето търсене тук",
"theme.SearchPage.noResultsText": "Не бяха намерени резултати"
}

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "এই পেজটি এডিট করুন",
"theme.common.headingLinkTitle": "{heading} এর সঙ্গে সরাসরি লিংকড",
"theme.common.skipToMainContent": "স্কিপ করে মূল কন্টেন্ট এ যান",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "ডক্স পেজ",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Upravit tuto stránku",
"theme.common.headingLinkTitle": "Přímý odkaz na {heading}",
"theme.common.skipToMainContent": "Přeskočit na hlavní obsah",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Stránka dokumentace",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Rediger denne side",
"theme.common.headingLinkTitle": "Direkte link til {heading}",
"theme.common.skipToMainContent": "Hop til hovedindhold",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Dokumentside",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Diese Seite bearbeiten",
"theme.common.headingLinkTitle": "Direkter Link zur {heading}",
"theme.common.skipToMainContent": "Zum Hauptinhalt springen",
"theme.docs.DocCard.categoryDescription.plurals": "{count} Einträge",
"theme.docs.DocCard.categoryDescription.plurals": "1 Eintrag|{count} Einträge",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Dokumentation Seiten",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Editar esta página",
"theme.common.headingLinkTitle": "Enlace directo al {heading}",
"theme.common.skipToMainContent": "Saltar al contenido principal",
"theme.docs.DocCard.categoryDescription.plurals": "{count} artículos",
"theme.docs.DocCard.categoryDescription.plurals": "1 artículo|{count} artículos",
"theme.docs.breadcrumbs.home": "Página de Inicio",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Página del documento",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "I-edit ang page",
"theme.common.headingLinkTitle": "Direktang link patungo sa {heading}",
"theme.common.skipToMainContent": "Lumaktaw patungo sa pangunahing content",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Docs Pages",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Éditer cette page",
"theme.common.headingLinkTitle": "Lien direct vers {heading}",
"theme.common.skipToMainContent": "Aller au contenu principal",
"theme.docs.DocCard.categoryDescription.plurals": "{count} éléments",
"theme.docs.DocCard.categoryDescription.plurals": "1 élément|{count} éléments",
"theme.docs.breadcrumbs.home": "Page d'accueil",
"theme.docs.breadcrumbs.navAriaLabel": "Fil d'Ariane",
"theme.docs.paginator.navAriaLabel": "Pages de documentation",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "ערוך דף זה",
"theme.common.headingLinkTitle": "קישור ישיר אל {heading}",
"theme.common.skipToMainContent": "דלג לתוכן הראשי",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "רשימת דוקומנטאציה",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "इस पेज को बदलें",
"theme.common.headingLinkTitle": "{heading} का सीधा लिंक",
"theme.common.skipToMainContent": "मुख्य कंटेंट तक स्किप करें",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "डॉक्स पेज",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Szerkesztés GitHub-on",
"theme.common.headingLinkTitle": "Közvetlen hivatkozás erre: {heading}",
"theme.common.skipToMainContent": "Ugrás a fő tartalomhoz",
"theme.docs.DocCard.categoryDescription.plurals": "{count} elemek",
"theme.docs.DocCard.categoryDescription.plurals": "1 elem|{count} elemek",
"theme.docs.breadcrumbs.home": "Kezdőlap",
"theme.docs.breadcrumbs.navAriaLabel": "Navigációs sáv a jelenlegi oldalhoz",
"theme.docs.paginator.navAriaLabel": "Dokumentációs oldal",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Modifica questa pagina",
"theme.common.headingLinkTitle": "Link diretto a {heading}",
"theme.common.skipToMainContent": "Passa al contenuto principale",
"theme.docs.DocCard.categoryDescription.plurals": "{count} elementi",
"theme.docs.DocCard.categoryDescription.plurals": "1 elemento|{count} elementi",
"theme.docs.breadcrumbs.home": "Pagina principale",
"theme.docs.breadcrumbs.navAriaLabel": "Briciole di pane",
"theme.docs.paginator.navAriaLabel": "Pagina del documento",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Rediger denne siden",
"theme.common.headingLinkTitle": "Direkte lenke til {heading}",
"theme.common.skipToMainContent": "Gå til hovedinnhold",
"theme.docs.DocCard.categoryDescription.plurals": "{count} artikler",
"theme.docs.DocCard.categoryDescription.plurals": "1 artikkel|{count} artikler",
"theme.docs.breadcrumbs.home": "Hjemmeside",
"theme.docs.breadcrumbs.navAriaLabel": "Søkvei",
"theme.docs.paginator.navAriaLabel": "Dokumenter side",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Bewerk deze pagina",
"theme.common.headingLinkTitle": "Direct link naar {heading}",
"theme.common.skipToMainContent": "Ga naar hoofdinhoud",
"theme.docs.DocCard.categoryDescription.plurals": "{count} artikelen",
"theme.docs.DocCard.categoryDescription.plurals": "1 artikel|{count} artikelen",
"theme.docs.breadcrumbs.home": "Homepagina",
"theme.docs.breadcrumbs.navAriaLabel": "Broodkruimels",
"theme.docs.paginator.navAriaLabel": "Documentatie pagina",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Edytuj tę stronę",
"theme.common.headingLinkTitle": "Bezpośredni link do {heading}",
"theme.common.skipToMainContent": "Przejdź do głównej zawartości",
"theme.docs.DocCard.categoryDescription.plurals": "{count} elementów",
"theme.docs.DocCard.categoryDescription.plurals": "1 element|{count} elementy|{count} elementów",
"theme.docs.breadcrumbs.home": "Strona główna",
"theme.docs.breadcrumbs.navAriaLabel": "Pasek nawigacji",
"theme.docs.paginator.navAriaLabel": "Strona dokumentacji",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Editar essa página",
"theme.common.headingLinkTitle": "Link direto para {heading}",
"theme.common.skipToMainContent": "Pular para o conteúdo principal",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Página Inicial",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Páginas de documentação",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Editar esta página",
"theme.common.headingLinkTitle": "Link direto para {heading}",
"theme.common.skipToMainContent": "Saltar para o conteúdo principal",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Páginas de documento",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Uredi to stran",
"theme.common.headingLinkTitle": "Direktna povezava na {heading}",
"theme.common.skipToMainContent": "Preskoči na vsebino",
"theme.docs.DocCard.categoryDescription.plurals": "{count} vnosov",
"theme.docs.DocCard.categoryDescription.plurals": "1 vnos|2 vnosy|{count} vnosy|{count} vnosov",
"theme.docs.breadcrumbs.home": "Domača stran",
"theme.docs.breadcrumbs.navAriaLabel": "Drobtine",
"theme.docs.paginator.navAriaLabel": "Strani z dokumenti",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Уреди ову страницу",
"theme.common.headingLinkTitle": "Веза до {heading}",
"theme.common.skipToMainContent": "Пређи на главни садржај",
"theme.docs.DocCard.categoryDescription.plurals": "{count} items",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "странице докумената",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Redigera denna sida",
"theme.common.headingLinkTitle": "Direktlänk till {heading}",
"theme.common.skipToMainContent": "Hoppa till huvudinnehåll",
"theme.docs.DocCard.categoryDescription.plurals": "{count} artiklar",
"theme.docs.DocCard.categoryDescription.plurals": "1 artikel|{count} artiklar",
"theme.docs.breadcrumbs.home": "Hemsida",
"theme.docs.breadcrumbs.navAriaLabel": "Sökväg",
"theme.docs.paginator.navAriaLabel": "Dokumentsidor",

View file

@ -40,7 +40,7 @@
"theme.common.editThisPage": "Bu sayfayı düzenle",
"theme.common.headingLinkTitle": "{heading} doğrudan bağlantı",
"theme.common.skipToMainContent": "Ana içeriğe geç",
"theme.docs.DocCard.categoryDescription.plurals": "{count} öğe",
"theme.docs.DocCard.categoryDescription.plurals": "1 öğe|{count} öğe",
"theme.docs.breadcrumbs.home": "Ana sayfa",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
"theme.docs.paginator.navAriaLabel": "Dokümanlar sayfası",

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import type {SiteStorage} from './context';
import type {RuleSetRule} from 'webpack';
import type {Required as RequireKeys, DeepPartial} from 'utility-types';
import type {I18nConfig} from './i18n';
@ -115,6 +116,15 @@ export type MarkdownConfig = {
anchors: MarkdownAnchorsConfig;
};
export type StorageConfig = {
type: SiteStorage['type'];
namespace: boolean | string;
};
export type FutureConfig = {
experimental_storage: StorageConfig;
};
/**
* Docusaurus config, after validation/normalization.
*/
@ -171,6 +181,11 @@ export type DocusaurusConfig = {
* @see https://docusaurus.io/docs/api/docusaurus-config#i18n
*/
i18n: I18nConfig;
/**
* Docusaurus future flags and experimental features.
* Similar to Remix future flags, see https://remix.run/blog/future-flags
*/
future: FutureConfig;
/**
* This option adds `<meta name="robots" content="noindex, nofollow">` to
* every page to tell search engines to avoid indexing your site.

View file

@ -27,6 +27,25 @@ export type SiteMetadata = {
readonly pluginVersions: {[pluginName: string]: PluginVersionInformation};
};
export type SiteStorage = {
/**
* Which browser storage do you want to use?
* Between "localStorage" and "sessionStorage".
* The default is "localStorage".
*/
type: 'localStorage' | 'sessionStorage';
/**
* Applies a namespace to the theme storage key
* For readability, the namespace is applied at the end of the key
* The final storage key will be = `${key}${namespace}`
*
* The default namespace is "" for retro-compatibility reasons
* If you want a separator, the namespace should contain it ("-myNamespace")
*/
namespace: string;
};
export type GlobalData = {[pluginName: string]: {[pluginId: string]: unknown}};
export type LoadContext = {
@ -50,6 +69,11 @@ export type LoadContext = {
baseUrl: string;
i18n: I18n;
codeTranslations: CodeTranslations;
/**
* Defines the default browser storage behavior for a site
*/
siteStorage: SiteStorage;
};
export type Props = LoadContext & {

View file

@ -12,6 +12,8 @@ export {
DefaultParseFrontMatter,
ParseFrontMatter,
DocusaurusConfig,
FutureConfig,
StorageConfig,
Config,
} from './config';
@ -20,6 +22,7 @@ export {
DocusaurusContext,
GlobalData,
LoadContext,
SiteStorage,
Props,
} from './context';

View file

@ -48,4 +48,30 @@ describe('docuHash', () => {
expect(docuHash(file)).toBe(asserts[file]);
});
});
it('docuHash works with hashLength option', () => {
const asserts: {[key: string]: string} = {
'': '-d41d8',
'/': 'index',
'/foo-bar': 'foo-bar-09652',
'/foo/bar': 'foo-bar-1df48',
};
Object.keys(asserts).forEach((file) => {
expect(docuHash(file, {hashLength: 5})).toBe(asserts[file]);
});
});
it('docuHash works with hashExtra option', () => {
expect(docuHash('')).toBe('-d41');
expect(docuHash('', {hashExtra: ''})).toBe('-d41');
expect(docuHash('', {hashExtra: 'some-extra'})).toBe('-928');
expect(docuHash('/')).toBe('index');
expect(docuHash('/', {hashExtra: ''})).toBe('index-6a9');
expect(docuHash('/', {hashExtra: 'some-extra'})).toBe('index-68e');
expect(docuHash('/foo/bar')).toBe('foo-bar-1df');
expect(docuHash('/foo/bar', {hashExtra: ''})).toBe('foo-bar-1df');
expect(docuHash('/foo/bar', {hashExtra: 'some-extra'})).toBe('foo-bar-7d4');
});
});

View file

@ -25,11 +25,28 @@ export function simpleHash(str: string, length: number): string {
* collision. Also removes part of the string if its larger than the allowed
* filename per OS, avoiding `ERRNAMETOOLONG` error.
*/
export function docuHash(str: string): string {
if (str === '/') {
export function docuHash(
strInput: string,
options?: {
// String that contributes to the hash value
// but does not contribute to the returned string
hashExtra?: string;
// Length of the hash to append
hashLength?: number;
},
): string {
// TODO check this historical behavior
// I'm not sure it makes sense to keep it...
if (strInput === '/' && typeof options?.hashExtra === 'undefined') {
return 'index';
}
const shortHash = simpleHash(str, 3);
const str = strInput === '/' ? 'index' : strInput;
const hashExtra = options?.hashExtra ?? '';
const hashLength = options?.hashLength ?? 3;
const stringToHash = str + hashExtra;
const shortHash = simpleHash(stringToHash, hashLength);
const parsedPath = `${_.kebabCase(str)}-${shortHash}`;
if (isNameTooLong(parsedPath)) {
return `${shortName(_.kebabCase(str))}-${shortHash}`;

View file

@ -15,11 +15,8 @@ import {handleBrokenLinks} from '../server/brokenLinks';
import {createBuildClientConfig} from '../webpack/client';
import createServerConfig from '../webpack/server';
import {
executePluginsConfigurePostCss,
executePluginsConfigureWebpack,
compile,
} from '../webpack/utils';
import {executePluginsConfigureWebpack} from '../webpack/configure';
import {compile} from '../webpack/utils';
import {PerfLogger} from '../utils';
import {loadI18n} from '../server/i18n';
@ -325,10 +322,6 @@ async function getBuildClientConfig({
bundleAnalyzer: cliOptions.bundleAnalyzer ?? false,
});
let {config} = result;
config = executePluginsConfigurePostCss({
plugins,
config,
});
config = executePluginsConfigureWebpack({
plugins,
config,

View file

@ -13,12 +13,11 @@ import WebpackDevServer from 'webpack-dev-server';
import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
import {createPollingOptions} from './watcher';
import {
executePluginsConfigurePostCss,
executePluginsConfigureWebpack,
formatStatsErrorMessage,
getHttpsConfig,
printStatsWarnings,
} from '../../webpack/utils';
import {executePluginsConfigureWebpack} from '../../webpack/configure';
import {createStartClientConfig} from '../../webpack/client';
import type {StartCLIOptions} from './start';
import type {Props} from '@docusaurus/types';
@ -135,7 +134,6 @@ async function getStartClientConfig({
minify,
poll,
});
config = executePluginsConfigurePostCss({plugins, config});
config = executePluginsConfigureWebpack({
plugins,
config,

View file

@ -7,6 +7,12 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -61,6 +67,12 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -115,6 +127,12 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -169,6 +187,12 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -223,6 +247,12 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -277,6 +307,12 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -331,6 +367,12 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -387,6 +429,12 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -443,6 +491,12 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -502,6 +556,12 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
],
"customFields": {},
"favicon": "img/docusaurus.ico",
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",

View file

@ -77,6 +77,12 @@ exports[`load loads props for site with custom i18n path 1`] = `
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [],
"i18n": {
"defaultLocale": "en",
@ -137,6 +143,10 @@ exports[`load loads props for site with custom i18n path 1`] = `
"pluginVersions": {},
"siteVersion": undefined,
},
"siteStorage": {
"namespace": "",
"type": "localStorage",
},
"siteVersion": undefined,
}
`;

View file

@ -148,6 +148,32 @@ describe('handleBrokenLinks', () => {
});
});
it('accepts valid non-strict link with anchor', async () => {
await testBrokenLinks({
routes: [{path: '/page1', strict: false}, {path: '/page2/'}],
collectedLinks: {
'/page1': {
links: [
'/page1#page1anchor',
'/page1/#page1anchor',
'/page2#page2anchor',
'/page2/#page2anchor',
],
anchors: ['page1anchor'],
},
'/page2/': {
links: [
'/page1#page1anchor',
'/page1/#page1anchor',
'/page2#page2anchor',
'/page2/#page2anchor',
],
anchors: ['page2anchor'],
},
},
});
});
it('accepts valid links and anchors, sparse arrays', async () => {
await testBrokenLinks({
routes: [{path: '/page1'}, {path: '/page2'}],

View file

@ -8,8 +8,10 @@
import {
ConfigSchema,
DEFAULT_CONFIG,
DEFAULT_STORAGE_CONFIG,
validateConfig,
} from '../configValidation';
import type {StorageConfig} from '@docusaurus/types/src/config';
import type {Config, DocusaurusConfig} from '@docusaurus/types';
import type {DeepPartial} from 'utility-types';
@ -35,6 +37,12 @@ describe('normalizeConfig', () => {
const userConfig: Config = {
...DEFAULT_CONFIG,
...baseConfig,
future: {
experimental_storage: {
type: 'sessionStorage',
namespace: true,
},
},
tagline: 'my awesome site',
organizationName: 'facebook',
projectName: 'docusaurus',
@ -588,12 +596,8 @@ describe('markdown', () => {
});
it('throw for bad markdown format', () => {
expect(() =>
normalizeConfig(
// @ts-expect-error: bad value
{markdown: {format: null}},
),
).toThrowErrorMatchingInlineSnapshot(`
expect(() => normalizeConfig({markdown: {format: null}}))
.toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect]
"markdown.format" must be a string
"
@ -612,7 +616,6 @@ describe('markdown', () => {
it('throw for null object', () => {
expect(() => {
normalizeConfig({
// @ts-expect-error: test
markdown: null,
});
}).toThrowErrorMatchingInlineSnapshot(`
@ -621,3 +624,292 @@ describe('markdown', () => {
`);
});
});
describe('future', () => {
it('accepts future - undefined', () => {
expect(
normalizeConfig({
future: undefined,
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts future - empty', () => {
expect(
normalizeConfig({
future: {},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts future', () => {
const future: DocusaurusConfig['future'] = {
experimental_storage: {
type: 'sessionStorage',
namespace: 'myNamespace',
},
};
expect(
normalizeConfig({
future,
}),
).toEqual(expect.objectContaining({future}));
});
it('rejects future - unknown key', () => {
const future: DocusaurusConfig['future'] = {
// @ts-expect-error: invalid
doesNotExistKey: {
type: 'sessionStorage',
namespace: 'myNamespace',
},
};
expect(() =>
normalizeConfig({
future,
}),
).toThrowErrorMatchingInlineSnapshot(`
"These field(s) ("future.doesNotExistKey",) are not recognized in docusaurus.config.js.
If you still want these fields to be in your configuration, put them in the "customFields" field.
See https://docusaurus.io/docs/api/docusaurus-config/#customfields"
`);
});
describe('storage', () => {
it('accepts storage - undefined', () => {
expect(
normalizeConfig({
future: {
experimental_storage: undefined,
},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts storage - empty', () => {
expect(
normalizeConfig({
future: {experimental_storage: {}},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts storage - full', () => {
const storage: StorageConfig = {
type: 'sessionStorage',
namespace: 'myNamespace',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: storage,
},
}),
);
});
it('rejects storage - boolean', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = true;
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage" must be of type object
"
`);
});
it('rejects storage - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = 42;
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage" must be of type object
"
`);
});
describe('type', () => {
it('accepts type', () => {
const storage: Partial<StorageConfig> = {
type: 'sessionStorage',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('accepts type - undefined', () => {
const storage: Partial<StorageConfig> = {
type: undefined,
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
type: 'localStorage',
},
},
}),
);
});
it('rejects type - null', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"future.experimental_storage.type" must be a string
"
`);
});
it('rejects type - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"future.experimental_storage.type" must be a string
"
`);
});
it('rejects type - invalid enum value', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 'badType'};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"
`);
});
});
describe('namespace', () => {
it('accepts namespace - boolean', () => {
const storage: Partial<StorageConfig> = {
namespace: true,
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('accepts namespace - string', () => {
const storage: Partial<StorageConfig> = {
namespace: 'myNamespace',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('rejects namespace - null', () => {
const storage: Partial<StorageConfig> = {namespace: null};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.namespace" must be one of [string, boolean]
"
`);
});
it('rejects namespace - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {namespace: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.namespace" must be one of [string, boolean]
"
`);
});
});
});
});

View file

@ -38,6 +38,10 @@ describe('load', () => {
siteConfig: {
baseUrl: '/zh-Hans/',
},
siteStorage: {
namespace: '',
type: 'localStorage',
},
plugins: site2.props.plugins,
}),
);

View file

@ -0,0 +1,165 @@
/**
* 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 {createSiteStorage} from '../storage';
import {
DEFAULT_FUTURE_CONFIG,
DEFAULT_STORAGE_CONFIG,
} from '../configValidation';
import type {FutureConfig, StorageConfig, SiteStorage} from '@docusaurus/types';
function test({
url = 'https://docusaurus.io',
baseUrl = '/',
storage = {},
}: {
url?: string;
baseUrl?: string;
storage?: Partial<StorageConfig>;
}): SiteStorage {
const future: FutureConfig = {
...DEFAULT_FUTURE_CONFIG,
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
};
return createSiteStorage({url, baseUrl, future});
}
const DefaultSiteStorage: SiteStorage = {
type: 'localStorage',
namespace: '',
};
describe('storage', () => {
it('default', () => {
expect(test({})).toEqual(DefaultSiteStorage);
});
describe('type', () => {
it('localStorage', () => {
expect(test({storage: {type: 'localStorage'}})).toEqual({
...DefaultSiteStorage,
type: 'localStorage',
});
});
it('sessionStorage', () => {
expect(test({storage: {type: 'sessionStorage'}})).toEqual({
...DefaultSiteStorage,
type: 'sessionStorage',
});
});
});
describe('namespace', () => {
describe('true', () => {
function testAutomaticNamespace(
{
url,
baseUrl,
}: {
url: string;
baseUrl: string;
},
expectedNamespace: string,
) {
return expect(test({url, baseUrl, storage: {namespace: true}})).toEqual(
expect.objectContaining({namespace: expectedNamespace}),
);
}
it('automatic namespace - https://docusaurus.io/', () => {
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/',
},
'-189',
);
});
it('automatic namespace - https://docusaurus.io/baseUrl/', () => {
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/baseUrl/',
},
'-b21',
);
});
it('automatic namespace - https://example.com/', () => {
testAutomaticNamespace(
{
url: 'https://example.com',
baseUrl: '/',
},
'-182',
);
});
it('automatic namespace - https://example.com/baseUrl/', () => {
testAutomaticNamespace(
{
url: 'https://example.com',
baseUrl: '/baseUrl/',
},
'-ad6',
);
});
it('automatic namespace - is not slash sensitive', () => {
const expectedNamespace = '-b21';
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/baseUrl/',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io/',
baseUrl: '/baseUrl/',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io/',
baseUrl: '/baseUrl',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: 'baseUrl',
},
expectedNamespace,
);
});
});
it('false', () => {
expect(test({storage: {namespace: false}})).toEqual({
...DefaultSiteStorage,
namespace: '',
});
});
it('string', () => {
expect(test({storage: {namespace: 'my-namespace'}})).toEqual({
...DefaultSiteStorage,
namespace: '-my-namespace',
});
});
});
});

View file

@ -125,7 +125,15 @@ function createBrokenLinksHelper({
return false;
}
const targetPage =
collectedLinks.get(pathname) || collectedLinks.get(decodeURI(pathname));
collectedLinks.get(pathname) ??
collectedLinks.get(decodeURI(pathname)) ??
// The broken link checker should not care about a trailing slash
// Those are already covered by the broken pathname checker
// See https://github.com/facebook/docusaurus/issues/10116
collectedLinks.get(addTrailingSlash(pathname)) ??
collectedLinks.get(addTrailingSlash(decodeURI(pathname))) ??
collectedLinks.get(removeTrailingSlash(pathname)) ??
collectedLinks.get(removeTrailingSlash(decodeURI(pathname)));
// link with anchor to a page that does not exist (or did not collect any
// link/anchor) is considered as a broken anchor
if (!targetPage) {

View file

@ -5,9 +5,75 @@
* LICENSE file in the root directory of this source tree.
*/
import {generateRoutesCode, genChunkName} from '../codegenRoutes';
import {
generateRoutesCode,
genChunkName,
generateRoutePropFilename,
} from '../codegenRoutes';
import type {RouteConfig} from '@docusaurus/types';
describe('generateRoutePropFilename', () => {
it('generate filename based on route path', () => {
expect(
generateRoutePropFilename({
path: '/some/route-path/',
component: '@theme/Home',
}),
).toEqual(expect.stringMatching(/^some-route-path-[a-z\d]{3}.json$/));
});
it('generate filename for /', () => {
expect(
generateRoutePropFilename({
path: '/',
component: '@theme/Home',
}),
).toEqual(expect.stringMatching(/^index-[a-z\d]{3}.json$/));
});
it('generate filename for /category/', () => {
expect(
generateRoutePropFilename({
path: '/category/',
component: '@theme/Home',
}),
).toEqual(expect.stringMatching(/^category-[a-z\d]{3}.json$/));
});
it('generate unique filenames for /', () => {
expect(
generateRoutePropFilename({path: '/', component: '@theme/Home'}),
).toEqual(generateRoutePropFilename({path: '/', component: '@theme/Home'}));
expect(
generateRoutePropFilename({path: '/', component: '@theme/Home'}),
).not.toEqual(
generateRoutePropFilename({
path: '/',
component: '@theme/AnotherComponent',
}),
);
});
it('generate unique filenames for /some/path', () => {
expect(
generateRoutePropFilename({path: '/some/path', component: '@theme/Home'}),
).toEqual(
generateRoutePropFilename({path: '/some/path', component: '@theme/Home'}),
);
expect(
generateRoutePropFilename({
path: '/some/path',
component: '@theme/Home',
}),
).not.toEqual(
generateRoutePropFilename({
path: '/some/path',
component: '@theme/AnotherComponent',
}),
);
});
});
describe('genChunkName', () => {
it('works', () => {
const firstAssert: {[key: string]: string} = {

View file

@ -18,6 +18,7 @@ import type {
I18n,
PluginRouteConfig,
SiteMetadata,
SiteStorage,
} from '@docusaurus/types';
function genWarning({generatedFilesDir}: {generatedFilesDir: string}) {
@ -131,6 +132,20 @@ function genSiteMetadata({
);
}
function genSiteStorage({
generatedFilesDir,
siteStorage,
}: {
generatedFilesDir: string;
siteStorage: SiteStorage;
}) {
return generate(
generatedFilesDir,
'site-storage.json',
JSON.stringify(siteStorage, null, 2),
);
}
type CodegenParams = {
generatedFilesDir: string;
siteConfig: DocusaurusConfig;
@ -140,6 +155,7 @@ type CodegenParams = {
i18n: I18n;
codeTranslations: CodeTranslations;
siteMetadata: SiteMetadata;
siteStorage: SiteStorage;
routes: PluginRouteConfig[];
};
@ -151,6 +167,7 @@ export async function generateSiteFiles(params: CodegenParams): Promise<void> {
generateRouteFiles(params),
genGlobalData(params),
genSiteMetadata(params),
genSiteStorage(params),
genI18n(params),
genCodeTranslations(params),
]);

View file

@ -320,6 +320,22 @@ type GenerateRouteFilesParams = {
baseUrl: string;
};
// The generated filename per route must be unique to avoid conflicts
// See also https://github.com/facebook/docusaurus/issues/10125
export function generateRoutePropFilename(route: RouteConfig): string {
// TODO if possible, we could try to shorten the filename by removing
// the plugin routeBasePath prefix from the name
return `${docuHash(
route.path,
// Note: using hash(route.path + route.component) is not technically
// as robust as hashing the entire prop content object.
// But it's faster and should be good enough considering it's very unlikely
// anyone would have 2 routes on the same path also rendering the exact
// same component.
{hashExtra: route.component},
)}.json`;
}
async function generateRoutePropModule({
generatedFilesDir,
route,
@ -339,7 +355,7 @@ async function generateRoutePropModule({
plugin.name,
plugin.id,
'p',
`${docuHash(route.path)}.json`,
generateRoutePropFilename(route),
);
const modulePath = path.posix.join(generatedFilesDir, relativePath);
const aliasedPath = path.posix.join('@generated', relativePath);
@ -376,29 +392,31 @@ async function preprocessRouteProps({
route: RouteConfig;
plugin: PluginIdentifier;
}): Promise<RouteConfig> {
const propsModulePathPromise = route.props
? generateRoutePropModule({
generatedFilesDir,
route,
plugin,
})
: undefined;
const getPropsModulePathPromise = () =>
route.props
? generateRoutePropModule({
generatedFilesDir,
route,
plugin,
})
: undefined;
const subRoutesPromise = route.routes
? Promise.all(
route.routes.map((subRoute: RouteConfig) => {
return preprocessRouteProps({
generatedFilesDir,
route: subRoute,
plugin,
});
}),
)
: undefined;
const getSubRoutesPromise = () =>
route.routes
? Promise.all(
route.routes.map((subRoute: RouteConfig) => {
return preprocessRouteProps({
generatedFilesDir,
route: subRoute,
plugin,
});
}),
)
: undefined;
const [propsModulePath, subRoutes] = await Promise.all([
propsModulePathPromise,
subRoutesPromise,
getPropsModulePathPromise(),
getSubRoutesPromise(),
]);
const newRoute: RouteConfig = {

View file

@ -16,6 +16,7 @@ import {
addLeadingSlash,
removeTrailingSlash,
} from '@docusaurus/utils-common';
import type {FutureConfig, StorageConfig} from '@docusaurus/types/src/config';
import type {
DocusaurusConfig,
I18nConfig,
@ -31,6 +32,15 @@ export const DEFAULT_I18N_CONFIG: I18nConfig = {
localeConfigs: {},
};
export const DEFAULT_STORAGE_CONFIG: StorageConfig = {
type: 'localStorage',
namespace: false,
};
export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
experimental_storage: DEFAULT_STORAGE_CONFIG,
};
export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
format: 'mdx', // TODO change this to "detect" in Docusaurus v4?
mermaid: false,
@ -50,6 +60,7 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
export const DEFAULT_CONFIG: Pick<
DocusaurusConfig,
| 'i18n'
| 'future'
| 'onBrokenLinks'
| 'onBrokenAnchors'
| 'onBrokenMarkdownLinks'
@ -71,6 +82,7 @@ export const DEFAULT_CONFIG: Pick<
| 'markdown'
> = {
i18n: DEFAULT_I18N_CONFIG,
future: DEFAULT_FUTURE_CONFIG,
onBrokenLinks: 'throw',
onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw
onBrokenMarkdownLinks: 'warn',
@ -181,6 +193,23 @@ const I18N_CONFIG_SCHEMA = Joi.object<I18nConfig>({
.optional()
.default(DEFAULT_I18N_CONFIG);
const STORAGE_CONFIG_SCHEMA = Joi.object({
type: Joi.string()
.equal('localStorage', 'sessionStorage')
.default(DEFAULT_STORAGE_CONFIG.type),
namespace: Joi.alternatives()
.try(Joi.string(), Joi.boolean())
.default(DEFAULT_STORAGE_CONFIG.namespace),
})
.optional()
.default(DEFAULT_STORAGE_CONFIG);
const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
experimental_storage: STORAGE_CONFIG_SCHEMA,
})
.optional()
.default(DEFAULT_FUTURE_CONFIG);
const SiteUrlSchema = Joi.string()
.required()
.custom((value: string, helpers) => {
@ -215,6 +244,7 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
url: SiteUrlSchema,
trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior!
i18n: I18N_CONFIG_SCHEMA,
future: FUTURE_CONFIG_SCHEMA,
onBrokenLinks: Joi.string()
.equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onBrokenLinks),

View file

@ -10,10 +10,36 @@ import {getLangDir} from 'rtl-detect';
import type {I18n, DocusaurusConfig, I18nLocaleConfig} from '@docusaurus/types';
import type {LoadContextParams} from './site';
function inferLanguageDisplayName(locale: string) {
const tryLocale = (l: string) => {
try {
return new Intl.DisplayNames(l, {
type: 'language',
fallback: 'code',
}).of(l)!;
} catch (e) {
// This is to compensate "of()" that is a bit strict
// Looks like starting Node 22, this locale throws: "en-US-u-ca-buddhist"
// RangeError: invalid_argument
return null;
}
};
const parts = locale.split('-');
// This is a best effort, we try various locale forms that could give a result
return (
tryLocale(locale) ??
tryLocale(`${parts[0]}-${parts[1]}`) ??
tryLocale(parts[0]!)
);
}
function getDefaultLocaleLabel(locale: string) {
const languageName = new Intl.DisplayNames(locale, {type: 'language'}).of(
locale,
)!;
const languageName = inferLanguageDisplayName(locale);
if (!languageName) {
return locale;
}
return (
languageName.charAt(0).toLocaleUpperCase(locale) + languageName.substring(1)
);
@ -44,13 +70,20 @@ function getDefaultCalendar(localeStr: string) {
}
export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig {
return {
label: getDefaultLocaleLabel(locale),
direction: getLangDir(locale),
htmlLang: locale,
calendar: getDefaultCalendar(locale),
path: locale,
};
try {
return {
label: getDefaultLocaleLabel(locale),
direction: getLangDir(locale),
htmlLang: locale,
calendar: getDefaultCalendar(locale),
path: locale,
};
} catch (e) {
throw new Error(
`Docusaurus couldn't get default locale config for ${locale}`,
{cause: e},
);
}
}
export async function loadI18n(

View file

@ -25,6 +25,7 @@ import {
import {PerfLogger} from '../utils';
import {generateSiteFiles} from './codegen/codegen';
import {getRoutesPaths, handleDuplicateRoutes} from './routes';
import {createSiteStorage} from './storage';
import type {LoadPluginsResult} from './plugins/plugins';
import type {
DocusaurusConfig,
@ -111,9 +112,12 @@ export async function loadContext(
const codeTranslations = await loadSiteCodeTranslations({localizationDir});
const siteStorage = createSiteStorage(siteConfig);
return {
siteDir,
siteVersion,
siteStorage,
generatedFilesDir,
localizationDir,
siteConfig,
@ -135,6 +139,7 @@ function createSiteProps(
siteVersion,
siteConfig,
siteConfigPath,
siteStorage,
outDir,
baseUrl,
i18n,
@ -159,6 +164,7 @@ function createSiteProps(
siteConfigPath,
siteMetadata,
siteVersion,
siteStorage,
siteDir,
outDir,
baseUrl,
@ -190,6 +196,7 @@ async function createSiteFiles({
generatedFilesDir,
siteConfig,
siteMetadata,
siteStorage,
i18n,
codeTranslations,
routes,
@ -202,6 +209,7 @@ async function createSiteFiles({
clientModules,
siteConfig,
siteMetadata,
siteStorage,
i18n,
codeTranslations,
globalData,
@ -224,7 +232,7 @@ export async function loadSite(params: LoadContextParams): Promise<Site> {
const {plugins, routes, globalData} = await loadPlugins(context);
const props = await createSiteProps({plugins, routes, globalData, context});
const props = createSiteProps({plugins, routes, globalData, context});
const site: Site = {props, params};
@ -253,7 +261,7 @@ export async function reloadSitePlugin(
context: site.props,
});
const newProps = await createSiteProps({
const newProps = createSiteProps({
plugins,
routes,
globalData,

View file

@ -0,0 +1,44 @@
/**
* 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 {normalizeUrl, simpleHash} from '@docusaurus/utils';
import {addTrailingSlash} from '@docusaurus/utils-common';
import type {DocusaurusConfig, SiteStorage} from '@docusaurus/types';
type PartialFuture = Pick<DocusaurusConfig['future'], 'experimental_storage'>;
type PartialConfig = Pick<DocusaurusConfig, 'url' | 'baseUrl'> & {
future: PartialFuture;
};
function automaticNamespace(config: PartialConfig): string {
const normalizedUrl = addTrailingSlash(
normalizeUrl([config.url, config.baseUrl]),
);
return simpleHash(normalizedUrl, 3);
}
function getNamespaceString(config: PartialConfig): string | null {
if (config.future.experimental_storage.namespace === true) {
return automaticNamespace(config);
} else if (config.future.experimental_storage.namespace === false) {
return null;
} else {
return config.future.experimental_storage.namespace;
}
}
export function createSiteStorage(config: PartialConfig): SiteStorage {
const {type} = config.future.experimental_storage;
const namespaceString = getNamespaceString(config);
const namespace = namespaceString ? `-${namespaceString}` : '';
return {
type,
namespace,
};
}

View file

@ -0,0 +1,458 @@
/**
* 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 * as path from 'path';
import * as webpack from 'webpack';
import {fromPartial} from '@total-typescript/shoehorn';
import {
applyConfigureWebpack,
applyConfigurePostCss,
executePluginsConfigureWebpack,
} from '../configure';
import type {Configuration} from 'webpack';
import type {LoadedPlugin, Plugin} from '@docusaurus/types';
describe('extending generated webpack config', () => {
it('direct mutation on generated webpack config object', async () => {
// Fake generated webpack config
let config: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
};
// @ts-expect-error: Testing an edge-case that we did not write types for
const configureWebpack: NonNullable<Plugin['configureWebpack']> = (
generatedConfig,
isServer,
) => {
if (!isServer) {
generatedConfig.entry = 'entry.js';
generatedConfig.output = {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
};
}
// Implicitly returning undefined to test null-safety
};
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
});
it('webpack-merge with user webpack config object', async () => {
let config: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
};
const configureWebpack: Plugin['configureWebpack'] = () => ({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
});
it('webpack-merge with custom strategy', async () => {
const config: Configuration = {
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}],
},
};
const createConfigureWebpack =
(mergeStrategy?: {
[key: string]: 'prepend' | 'append';
}): NonNullable<Plugin['configureWebpack']> =>
() => ({
module: {
rules: [{use: 'zzz'}],
},
mergeStrategy,
});
const defaultStrategyMergeConfig = applyConfigureWebpack(
createConfigureWebpack(),
config,
false,
undefined,
{content: 42},
);
expect(defaultStrategyMergeConfig).toEqual({
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}],
},
});
const prependRulesStrategyConfig = applyConfigureWebpack(
createConfigureWebpack({'module.rules': 'prepend'}),
config,
false,
undefined,
{content: 42},
);
expect(prependRulesStrategyConfig).toEqual({
module: {
rules: [{use: 'zzz'}, {use: 'xxx'}, {use: 'yyy'}],
},
});
const uselessMergeStrategyConfig = applyConfigureWebpack(
createConfigureWebpack({uselessAttributeName: 'append'}),
config,
false,
undefined,
{content: 42},
);
expect(uselessMergeStrategyConfig).toEqual({
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}],
},
});
});
});
describe('extending PostCSS', () => {
it('user plugin should be appended in PostCSS loader', () => {
let webpackConfig: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
module: {
rules: [
{
test: 'any',
use: [
{
loader: 'some-loader-1',
options: {},
},
{
loader: 'some-loader-2',
options: {},
},
{
loader: 'postcss-loader-1',
options: {
postcssOptions: {
plugins: [['default-postcss-loader-1-plugin']],
},
},
},
{
loader: 'some-loader-3',
options: {},
},
],
},
{
test: '2nd-test',
use: [
{
loader: 'postcss-loader-2',
options: {
postcssOptions: {
plugins: [['default-postcss-loader-2-plugin']],
},
},
},
],
},
],
},
};
function createFakePlugin(name: string) {
return [name, {}];
}
// Run multiple times: ensure last run does not override previous runs
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
...postCssOptions.plugins,
createFakePlugin('postcss-plugin-1'),
],
}),
webpackConfig,
);
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
createFakePlugin('postcss-plugin-2'),
...postCssOptions.plugins,
],
}),
webpackConfig,
);
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
...postCssOptions.plugins,
createFakePlugin('postcss-plugin-3'),
],
}),
webpackConfig,
);
// @ts-expect-error: relax type
const postCssLoader1 = webpackConfig.module?.rules[0].use[2];
expect(postCssLoader1.loader).toBe('postcss-loader-1');
const pluginNames1 = postCssLoader1.options.postcssOptions.plugins.map(
(p: unknown[]) => p[0],
);
expect(pluginNames1).toHaveLength(4);
expect(pluginNames1).toEqual([
'postcss-plugin-2',
'default-postcss-loader-1-plugin',
'postcss-plugin-1',
'postcss-plugin-3',
]);
// @ts-expect-error: relax type
const postCssLoader2 = webpackConfig.module?.rules[1].use[0];
expect(postCssLoader2.loader).toBe('postcss-loader-2');
const pluginNames2 = postCssLoader2.options.postcssOptions.plugins.map(
(p: unknown[]) => p[0],
);
expect(pluginNames2).toHaveLength(4);
expect(pluginNames2).toEqual([
'postcss-plugin-2',
'default-postcss-loader-2-plugin',
'postcss-plugin-1',
'postcss-plugin-3',
]);
});
});
describe('executePluginsConfigureWebpack', () => {
function fakePlugin(partialPlugin: Partial<LoadedPlugin>): LoadedPlugin {
return fromPartial({
...partialPlugin,
});
}
it('can merge Webpack aliases of 2 plugins into base config', () => {
const config = executePluginsConfigureWebpack({
config: {resolve: {alias: {'initial-alias': 'initial-alias-value'}}},
isServer: false,
jsLoader: 'babel',
plugins: [
fakePlugin({
configureWebpack: () => {
return {resolve: {alias: {'p1-alias': 'p1-alias-value'}}};
},
}),
fakePlugin({
configureWebpack: () => {
return {resolve: {alias: {'p2-alias': 'p2-alias-value'}}};
},
}),
],
});
expect(config).toMatchInlineSnapshot(
{},
`
{
"resolve": {
"alias": {
"initial-alias": "initial-alias-value",
"p1-alias": "p1-alias-value",
"p2-alias": "p2-alias-value",
},
},
}
`,
);
});
it('can configurePostCSS() for all loaders added through configureWebpack()', () => {
const config = executePluginsConfigureWebpack({
config: {},
isServer: false,
jsLoader: 'babel',
plugins: [
fakePlugin({
configurePostCss: (postCssOptions) => {
// Imperative mutation should work
postCssOptions.plugins.push('p1-added-postcss-plugin');
return postCssOptions;
},
configureWebpack: () => {
return {
module: {
rules: [
{
test: /\.module.scss$/,
use: 'some-loader',
options: {
postcssOptions: {
plugins: ['p1-initial-postcss-plugin'],
},
},
},
],
},
};
},
}),
fakePlugin({
configurePostCss: (postCssOptions) => {
postCssOptions.plugins.push('p2-added-postcss-plugin');
return postCssOptions;
},
configureWebpack: () => {
return {
module: {
rules: [
{
test: /\.module.scss$/,
use: [
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: ['p2-initial-postcss-plugin'],
},
},
},
],
},
],
},
};
},
}),
fakePlugin({
configurePostCss: (postCssOptions) => {
// Functional/immutable copy mutation should work
return {
...postCssOptions,
plugins: [...postCssOptions.plugins, 'p3-added-postcss-plugin'],
};
},
configureWebpack: () => {
return {
module: {
rules: [
{
test: /\.module.scss$/,
oneOf: [
{
use: 'some-loader',
options: {
postcssOptions: {
plugins: ['p3-initial-postcss-plugin'],
},
},
},
],
},
],
},
};
},
}),
],
});
expect(config.module.rules).toHaveLength(3);
expect(config.module.rules[0]).toMatchInlineSnapshot(`
{
"options": {
"postcssOptions": {
"plugins": [
"p1-initial-postcss-plugin",
"p1-added-postcss-plugin",
"p2-added-postcss-plugin",
"p3-added-postcss-plugin",
],
},
},
"test": /\\\\\\.module\\.scss\\$/,
"use": "some-loader",
}
`);
expect(config.module.rules[1]).toMatchInlineSnapshot(`
{
"test": /\\\\\\.module\\.scss\\$/,
"use": [
{
"loader": "postcss-loader",
"options": {
"postcssOptions": {
"plugins": [
"p2-initial-postcss-plugin",
"p1-added-postcss-plugin",
"p2-added-postcss-plugin",
"p3-added-postcss-plugin",
],
},
},
},
],
}
`);
expect(config.module.rules[2]).toMatchInlineSnapshot(`
{
"oneOf": [
{
"options": {
"postcssOptions": {
"plugins": [
"p3-initial-postcss-plugin",
"p1-added-postcss-plugin",
"p2-added-postcss-plugin",
"p3-added-postcss-plugin",
],
},
},
"use": "some-loader",
},
],
"test": /\\\\\\.module\\.scss\\$/,
}
`);
});
});

View file

@ -6,15 +6,8 @@
*/
import path from 'path';
import webpack, {type Configuration, type RuleSetRule} from 'webpack';
import {
getCustomizableJSLoader,
applyConfigureWebpack,
applyConfigurePostCss,
getHttpsConfig,
} from '../utils';
import type {Plugin} from '@docusaurus/types';
import {getCustomizableJSLoader, getHttpsConfig} from '../utils';
import type {RuleSetRule} from 'webpack';
describe('customize JS loader', () => {
it('getCustomizableJSLoader defaults to babel loader', () => {
@ -50,255 +43,6 @@ describe('customize JS loader', () => {
});
});
describe('extending generated webpack config', () => {
it('direct mutation on generated webpack config object', async () => {
// Fake generated webpack config
let config: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
};
// @ts-expect-error: Testing an edge-case that we did not write types for
const configureWebpack: NonNullable<Plugin['configureWebpack']> = (
generatedConfig,
isServer,
) => {
if (!isServer) {
generatedConfig.entry = 'entry.js';
generatedConfig.output = {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
};
}
// Implicitly returning undefined to test null-safety
};
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
});
it('webpack-merge with user webpack config object', async () => {
let config: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
};
const configureWebpack: Plugin['configureWebpack'] = () => ({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
config = applyConfigureWebpack(configureWebpack, config, false, undefined, {
content: 42,
});
expect(config).toEqual({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
filename: 'new.bundle.js',
},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
});
it('webpack-merge with custom strategy', async () => {
const config: Configuration = {
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}],
},
};
const createConfigureWebpack =
(mergeStrategy?: {
[key: string]: 'prepend' | 'append';
}): NonNullable<Plugin['configureWebpack']> =>
() => ({
module: {
rules: [{use: 'zzz'}],
},
mergeStrategy,
});
const defaultStrategyMergeConfig = applyConfigureWebpack(
createConfigureWebpack(),
config,
false,
undefined,
{content: 42},
);
expect(defaultStrategyMergeConfig).toEqual({
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}],
},
});
const prependRulesStrategyConfig = applyConfigureWebpack(
createConfigureWebpack({'module.rules': 'prepend'}),
config,
false,
undefined,
{content: 42},
);
expect(prependRulesStrategyConfig).toEqual({
module: {
rules: [{use: 'zzz'}, {use: 'xxx'}, {use: 'yyy'}],
},
});
const uselessMergeStrategyConfig = applyConfigureWebpack(
createConfigureWebpack({uselessAttributeName: 'append'}),
config,
false,
undefined,
{content: 42},
);
expect(uselessMergeStrategyConfig).toEqual({
module: {
rules: [{use: 'xxx'}, {use: 'yyy'}, {use: 'zzz'}],
},
});
});
});
describe('extending PostCSS', () => {
it('user plugin should be appended in PostCSS loader', () => {
let webpackConfig: Configuration = {
output: {
path: __dirname,
filename: 'bundle.js',
},
module: {
rules: [
{
test: 'any',
use: [
{
loader: 'some-loader-1',
options: {},
},
{
loader: 'some-loader-2',
options: {},
},
{
loader: 'postcss-loader-1',
options: {
postcssOptions: {
plugins: [['default-postcss-loader-1-plugin']],
},
},
},
{
loader: 'some-loader-3',
options: {},
},
],
},
{
test: '2nd-test',
use: [
{
loader: 'postcss-loader-2',
options: {
postcssOptions: {
plugins: [['default-postcss-loader-2-plugin']],
},
},
},
],
},
],
},
};
function createFakePlugin(name: string) {
return [name, {}];
}
// Run multiple times: ensure last run does not override previous runs
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
...postCssOptions.plugins,
createFakePlugin('postcss-plugin-1'),
],
}),
webpackConfig,
);
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
createFakePlugin('postcss-plugin-2'),
...postCssOptions.plugins,
],
}),
webpackConfig,
);
webpackConfig = applyConfigurePostCss(
(postCssOptions) => ({
...postCssOptions,
plugins: [
...postCssOptions.plugins,
createFakePlugin('postcss-plugin-3'),
],
}),
webpackConfig,
);
// @ts-expect-error: relax type
const postCssLoader1 = webpackConfig.module?.rules[0].use[2];
expect(postCssLoader1.loader).toBe('postcss-loader-1');
const pluginNames1 = postCssLoader1.options.postcssOptions.plugins.map(
(p: unknown[]) => p[0],
);
expect(pluginNames1).toHaveLength(4);
expect(pluginNames1).toEqual([
'postcss-plugin-2',
'default-postcss-loader-1-plugin',
'postcss-plugin-1',
'postcss-plugin-3',
]);
// @ts-expect-error: relax type
const postCssLoader2 = webpackConfig.module?.rules[1].use[0];
expect(postCssLoader2.loader).toBe('postcss-loader-2');
const pluginNames2 = postCssLoader2.options.postcssOptions.plugins.map(
(p: unknown[]) => p[0],
);
expect(pluginNames2).toHaveLength(4);
expect(pluginNames2).toEqual([
'postcss-plugin-2',
'default-postcss-loader-2-plugin',
'postcss-plugin-1',
'postcss-plugin-3',
]);
});
});
describe('getHttpsConfig', () => {
const originalEnv = process.env;

View file

@ -0,0 +1,156 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
mergeWithCustomize,
customizeArray,
customizeObject,
} from 'webpack-merge';
import {getCustomizableJSLoader, getStyleLoaders} from './utils';
import type {Configuration, RuleSetRule} from 'webpack';
import type {
Plugin,
PostCssOptions,
ConfigureWebpackUtils,
LoadedPlugin,
} from '@docusaurus/types';
/**
* Helper function to modify webpack config
* @param configureWebpack a webpack config or a function to modify config
* @param config initial webpack config
* @param isServer indicates if this is a server webpack configuration
* @param jsLoader custom js loader config
* @param content content loaded by the plugin
* @returns final/ modified webpack config
*/
export function applyConfigureWebpack(
configureWebpack: NonNullable<Plugin['configureWebpack']>,
config: Configuration,
isServer: boolean,
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined,
content: unknown,
): Configuration {
// Export some utility functions
const utils: ConfigureWebpackUtils = {
getStyleLoaders,
getJSLoader: getCustomizableJSLoader(jsLoader),
};
if (typeof configureWebpack === 'function') {
const {mergeStrategy, ...res} =
configureWebpack(config, isServer, utils, content) ?? {};
const customizeRules = mergeStrategy ?? {};
return mergeWithCustomize({
customizeArray: customizeArray(customizeRules),
customizeObject: customizeObject(customizeRules),
})(config, res);
}
return config;
}
export function applyConfigurePostCss(
configurePostCss: NonNullable<Plugin['configurePostCss']>,
config: Configuration,
): Configuration {
type LocalPostCSSLoader = object & {
options: {postcssOptions: PostCssOptions};
};
// Not ideal heuristic but good enough for our use-case?
function isPostCssLoader(loader: unknown): loader is LocalPostCSSLoader {
return !!(loader as LocalPostCSSLoader)?.options?.postcssOptions;
}
// Does not handle all edge cases, but good enough for now
function overridePostCssOptions(entry: RuleSetRule) {
if (isPostCssLoader(entry)) {
entry.options.postcssOptions = configurePostCss(
entry.options.postcssOptions,
);
} else if (Array.isArray(entry.oneOf)) {
entry.oneOf.forEach((r) => {
if (r) {
overridePostCssOptions(r);
}
});
} else if (Array.isArray(entry.use)) {
entry.use
.filter((u) => typeof u === 'object')
.forEach((rule) => overridePostCssOptions(rule as RuleSetRule));
}
}
config.module?.rules?.forEach((rule) =>
overridePostCssOptions(rule as RuleSetRule),
);
return config;
}
// Plugin Lifecycle - configurePostCss()
function executePluginsConfigurePostCss({
plugins,
config,
}: {
plugins: LoadedPlugin[];
config: Configuration;
}): Configuration {
let resultConfig = config;
plugins.forEach((plugin) => {
const {configurePostCss} = plugin;
if (configurePostCss) {
resultConfig = applyConfigurePostCss(
configurePostCss.bind(plugin),
resultConfig,
);
}
});
return resultConfig;
}
// Plugin Lifecycle - configureWebpack()
export function executePluginsConfigureWebpack({
plugins,
config,
isServer,
jsLoader,
}: {
plugins: LoadedPlugin[];
config: Configuration;
isServer: boolean;
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined;
}): Configuration {
// Step1 - Configure Webpack
let resultConfig = config;
plugins.forEach((plugin) => {
const {configureWebpack} = plugin;
if (configureWebpack) {
resultConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
resultConfig,
isServer,
jsLoader,
plugin.content,
);
}
});
// Step2 - For client code, configure PostCSS
// The order matters! We want to configure PostCSS on loaders
// that were potentially added by configureWebpack
// See https://github.com/facebook/docusaurus/issues/10106
// Note: it's useless to configure postCSS for the server
if (!isServer) {
resultConfig = executePluginsConfigurePostCss({
plugins,
config: resultConfig,
});
}
return resultConfig;
}

View file

@ -11,20 +11,9 @@ import crypto from 'crypto';
import logger from '@docusaurus/logger';
import {BABEL_CONFIG_FILE_NAME} from '@docusaurus/utils';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import {
mergeWithCustomize,
customizeArray,
customizeObject,
} from 'webpack-merge';
import webpack, {type Configuration, type RuleSetRule} from 'webpack';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import type {TransformOptions} from '@babel/core';
import type {
Plugin,
PostCssOptions,
ConfigureWebpackUtils,
LoadedPlugin,
} from '@docusaurus/types';
export function formatStatsErrorMessage(
statsJson: ReturnType<webpack.Stats['toJson']> | undefined,
@ -181,129 +170,6 @@ export const getCustomizableJSLoader =
? getDefaultBabelLoader({isServer, babelOptions})
: jsLoader(isServer);
/**
* Helper function to modify webpack config
* @param configureWebpack a webpack config or a function to modify config
* @param config initial webpack config
* @param isServer indicates if this is a server webpack configuration
* @param jsLoader custom js loader config
* @param content content loaded by the plugin
* @returns final/ modified webpack config
*/
export function applyConfigureWebpack(
configureWebpack: NonNullable<Plugin['configureWebpack']>,
config: Configuration,
isServer: boolean,
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined,
content: unknown,
): Configuration {
// Export some utility functions
const utils: ConfigureWebpackUtils = {
getStyleLoaders,
getJSLoader: getCustomizableJSLoader(jsLoader),
};
if (typeof configureWebpack === 'function') {
const {mergeStrategy, ...res} =
configureWebpack(config, isServer, utils, content) ?? {};
const customizeRules = mergeStrategy ?? {};
return mergeWithCustomize({
customizeArray: customizeArray(customizeRules),
customizeObject: customizeObject(customizeRules),
})(config, res);
}
return config;
}
export function applyConfigurePostCss(
configurePostCss: NonNullable<Plugin['configurePostCss']>,
config: Configuration,
): Configuration {
type LocalPostCSSLoader = object & {
options: {postcssOptions: PostCssOptions};
};
// Not ideal heuristic but good enough for our use-case?
function isPostCssLoader(loader: unknown): loader is LocalPostCSSLoader {
return !!(loader as LocalPostCSSLoader)?.options?.postcssOptions;
}
// Does not handle all edge cases, but good enough for now
function overridePostCssOptions(entry: RuleSetRule) {
if (isPostCssLoader(entry)) {
entry.options.postcssOptions = configurePostCss(
entry.options.postcssOptions,
);
} else if (Array.isArray(entry.oneOf)) {
entry.oneOf.forEach((r) => {
if (r) {
overridePostCssOptions(r);
}
});
} else if (Array.isArray(entry.use)) {
entry.use
.filter((u) => typeof u === 'object')
.forEach((rule) => overridePostCssOptions(rule as RuleSetRule));
}
}
config.module?.rules?.forEach((rule) =>
overridePostCssOptions(rule as RuleSetRule),
);
return config;
}
// Plugin Lifecycle - configurePostCss()
export function executePluginsConfigurePostCss({
plugins,
config,
}: {
plugins: LoadedPlugin[];
config: Configuration;
}): Configuration {
let resultConfig = config;
plugins.forEach((plugin) => {
const {configurePostCss} = plugin;
if (configurePostCss) {
resultConfig = applyConfigurePostCss(
configurePostCss.bind(plugin),
resultConfig,
);
}
});
return resultConfig;
}
// Plugin Lifecycle - configureWebpack()
export function executePluginsConfigureWebpack({
plugins,
config,
isServer,
jsLoader,
}: {
plugins: LoadedPlugin[];
config: Configuration;
isServer: boolean;
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined;
}): Configuration {
let resultConfig = config;
plugins.forEach((plugin) => {
const {configureWebpack} = plugin;
if (configureWebpack) {
resultConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
resultConfig,
isServer,
jsLoader,
plugin.content,
);
}
});
return resultConfig;
}
declare global {
interface Error {
/** @see https://webpack.js.org/api/node/#error-handling */

View file

@ -104,6 +104,7 @@ export const dogfoodingPluginInstances: PluginConfig[] = [
return [
require.resolve('./clientModuleExample.ts'),
require.resolve('./clientModuleCSS.css'),
require.resolve('./migrateStorageNamespace.ts'),
];
},
};

View file

@ -0,0 +1,31 @@
/**
* 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import SiteStorage from '@generated/site-storage';
// The purpose is to test a migration script for storage namespacing
// See also: https://github.com/facebook/docusaurus/pull/10121
if (ExecutionEnvironment.canUseDOM) {
const migrateStorageKey = (key: string) => {
const value = localStorage.getItem(key);
if (value !== null && SiteStorage.namespace) {
const newKey = `${key}${SiteStorage.namespace}`;
console.log(`Updating storage key [${key} => ${newKey}], value=${value}`);
localStorage.setItem(newKey, value);
localStorage.removeItem(key);
}
};
const storageKeys = [
'theme',
'docusaurus.announcement.id',
'docusaurus.announcement.dismiss',
'docs-preferred-version-default',
];
storageKeys.forEach(migrateStorageKey);
}

View file

@ -7,6 +7,12 @@ slug: /preparing-your-site-for-docusaurus-v3
image: ./img/social-card.png
---
:::warning
This blog post was written when Docusaurus v3 was in beta. There are some changes in dependency versions and upgrade steps you should be aware of if upgrading to Docusaurus v3 current stable releases. Use the [upgrade guide](https://docusaurus.io/docs/next/migration/v3) for the most up-to-date migration steps.
:::
**Docusaurus v3** is now [**in beta**](https://github.com/facebook/docusaurus/discussions/9312) and the official release is around the corner. This is the perfect time to start **preparing your site** for this new major version.
Docusaurus v3 comes with a few **breaking changes**, many of which can be **handled today under Docusaurus v2**. Preparing your site ahead of time can be done incrementally, and will make it easier to upgrade to v3.

View file

@ -21,7 +21,7 @@ import IframeWindow from '@site/src/components/BrowserWindow/IframeWindow';
### Prepare for React 19
The React core team recently [released the first **React 19 beta**](https://react.dev/blog/2024/04/25/react-19). They also [published an upgrade guide and a ** React v18.3 release**](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) with new warnings to help us identify issues **before upgrading to React 19**.
The React core team recently [released the first **React 19 beta**](https://react.dev/blog/2024/04/25/react-19). They also [published an upgrade guide and a **React v18.3 release**](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) with new warnings to help us identify issues **before upgrading to React 19**.
Docusaurus v3 depends on React `18.x`. When initializing a new Docusaurus sites, it will use that new React `18.3` release. It's also the case if you decide to upgrade your dependencies, or re-generate your package manager lockfile.

View file

@ -156,6 +156,12 @@ We will outline what accounts as the public API surface.
- `@docusaurus/types` TypeScript types
- We still retain the freedom to make types stricter (which may break type-checking).
❌ Our public API **excludes**:
- Docusaurus config `future`
- All features prefixed by `experimental_` or `unstable_`
- All features prefixed by `v<MajorVersion>_` (`v6_` `v7_`, etc.)
:::tip
For non-theme APIs, any documented API is considered public (and will be stable); any undocumented API is considered internal.

View file

@ -174,6 +174,41 @@ export default {
- `calendar`: the [calendar](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar) used to calculate the date era. Note that it doesn't control the actual string displayed: `MM/DD/YYYY` and `DD/MM/YYYY` are both `gregory`. To choose the format (`DD/MM/YYYY` or `MM/DD/YYYY`), set your locale name to `en-GB` or `en-US` (`en` means `en-US`).
- `path`: Root folder that all plugin localization folders of this locale are relative to. Will be resolved against `i18n.path`. Defaults to the locale's name. Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress.
### `future` {#future}
- Type: `Object`
The `future` configuration object permits to opt-in for upcoming/unstable/experimental Docusaurus features that are not ready for prime time.
It is also a way to opt-in for upcoming breaking changes coming in the next major versions, enabling you to prepare your site for the next version while staying on the previous one. The [Remix Future Flags blog post](https://remix.run/blog/future-flags) greatly explains this idea.
:::danger Breaking changes in minor versions
Features prefixed by `experimental_` or `unstable_` are subject to changes in **minor versions**, and not considered as [Semantic Versioning breaking changes](/community/release-process).
Features prefixed by `v<MajorVersion>_` (`v6_` `v7_`, etc.) are future flags that are expected to be turned on by default in the next major versions. These are less likely to change, but we keep the possibility to do so.
`future` API breaking changes should be easy to handle, and will be documented in minor/major version blog posts.
:::
Example:
```js title="docusaurus.config.js"
export default {
future: {
experimental_storage: {
type: 'localStorage',
namespace: true,
},
},
};
```
- `experimental_storage`: Site-wide browser storage options that theme authors should strive to respect.
- `type`: The browser storage theme authors should use. Possible values are `localStorage` and `sessionStorage`. Defaults to `localStorage`.
- `namespace`: Whether to namespace the browser storage keys to avoid storage key conflicts when Docusaurus sites are hosted under the same domain, or on localhost. Possible values are `string | boolean`. The namespace is appended at the end of the storage keys `key-namespace`. Use `true` to automatically generate a random namespace from your site `url + baseUrl`. Defaults to `false` (no namespace, historical behavior).
### `noIndex` {#noIndex}
- Type: `boolean`

View file

@ -147,6 +147,11 @@ export default async function createConfigAsync() {
baseUrl,
baseUrlIssueBanner: true,
url: 'https://docusaurus.io',
future: {
experimental_storage: {
namespace: true,
},
},
// Dogfood both settings:
// - force trailing slashes for deploy previews
// - avoid trailing slashes in prod