Merge branch 'main' into ozaki/execa

This commit is contained in:
sebastien 2024-08-09 11:57:27 +02:00
commit 6c4a1d7891
147 changed files with 5409 additions and 981 deletions

View file

@ -32,6 +32,8 @@
"website/_dogfooding/_pages tests/diagrams.mdx",
"*.xyz",
"*.docx",
"*.xsl",
"*.xslt",
"*.gitignore",
"versioned_docs",
"*.min.*",

View file

@ -0,0 +1,35 @@
name: Continuous Releases
on:
push:
branches:
- main
- docusaurus-v**
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
release:
name: Continuous Releases
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Installation
run: yarn
- name: Build packages
run: yarn build:packages
- name: Initialize fresh templates
run: |
yarn create-docusaurus template/docusaurus-classic-js classic --javascript -p npm
yarn create-docusaurus template/docusaurus-classic-ts classic --typescript -p npm
- name: Release
run: npx pkg-pr-new publish './packages/*' --template './template/*' --compact --comment=off

View file

@ -5,6 +5,9 @@ build
coverage
.docusaurus
.svg
*.svg
jest/vendor
packages/lqip-loader/lib/

View file

@ -67,6 +67,7 @@
},
"devDependencies": {
"@crowdin/cli": "^3.13.0",
"@prettier/plugin-xml": "^2.2.0",
"@swc/core": "1.2.197",
"@swc/jest": "^0.2.26",
"@testing-library/react-hooks": "^8.0.1",
@ -105,7 +106,7 @@
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.8.4",
"prettier": "^2.8.8",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-helmet-async": "^1.3.0",

View file

@ -42,6 +42,10 @@ const config: Config = {
},
blog: {
showReadingTime: true,
feedOptions: {
type: ['rss', 'atom'],
xslt: true,
},
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:

View file

@ -48,6 +48,10 @@ const config = {
},
blog: {
showReadingTime: true,
feedOptions: {
type: ['rss', 'atom'],
xslt: true,
},
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl:

View file

@ -0,0 +1,75 @@
/**
* 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.
*/
main {
flex: 1 0 auto;
width: 100%;
margin: 2rem auto;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 2rem 0;
padding: 1.6rem 2.4rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #edf5ff;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.4rem;
font-weight: 800;
margin-bottom: 2rem;
display: flex;
align-items: center;
}
h1 .rss-icon {
height: 3.2rem;
width: 3.2rem;
margin-right: 1rem;
}
h2 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
h3 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.1rem;
}
.blog-description {
font-size: 1.4rem;
margin-bottom: 0.6rem;
}
.blog-post-date {
font-size: 1rem;
line-height: 1.4rem;
font-style: italic;
color: #797b7e;
}
.blog-post-description {
font-size: 1rem;
line-height: 1.4rem;
color: #434349;
}

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet
version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Atom Feed | <xsl:value-of
select="atom:feed/atom:title"
/></title>
<link rel="stylesheet" href="atom.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an Atom feed</strong>. Subscribe by copying the URL
from the address bar into your newsreader. Visit
<a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free.
</div>
<h1>
<div class="rss-icon">
<svg
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731"
xml:space="preserve">
<g>
<rect
x="0"
y="0"
style="fill: #f78422"
width="455.731"
height="455.731"
/>
<g>
<path
style="fill: #ffffff"
d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"
/>
<path
style="fill: #ffffff"
d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"
/>
<circle
style="fill: #ffffff"
cx="109.833"
cy="346.26"
r="46.088"
/>
</g>
</g>
</svg>
</div>
<xsl:value-of select="atom:feed/atom:title" />
</h1>
<p class="blog-description">
<xsl:value-of select="atom:feed/atom:subtitle" />
</p>
</div>
<h2>Recent Posts</h2>
<div class="blog-posts">
<xsl:for-each select="atom:feed/atom:entry">
<div class="blog-post">
<h3><a href="{atom:link[@rel='alternate']/@href}"><xsl:value-of
select="atom:title"
/></a></h3>
<div class="blog-post-date">
Published on <xsl:value-of
select="substring(atom:updated,0,11)"
/>
</div>
<div class="blog-post-description">
<xsl:value-of select="atom:summary" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,75 @@
/**
* 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.
*/
main {
flex: 1 0 auto;
width: 100%;
margin: 2rem auto;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 2rem 0;
padding: 1.6rem 2.4rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #edf5ff;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.4rem;
font-weight: 800;
margin-bottom: 2rem;
display: flex;
align-items: center;
}
h1 .rss-icon {
height: 3.2rem;
width: 3.2rem;
margin-right: 1rem;
}
h2 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
h3 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.1rem;
}
.blog-description {
font-size: 1.4rem;
margin-bottom: 0.6rem;
}
.blog-post-date {
font-size: 1rem;
line-height: 1.4rem;
font-style: italic;
color: #797b7e;
}
.blog-post-description {
font-size: 1rem;
line-height: 1.4rem;
color: #434349;
}

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet
version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>RSS Feed | <xsl:value-of select="rss/channel/title" /></title>
<link rel="stylesheet" href="rss.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an RSS feed</strong>. Subscribe by copying the URL
from the address bar into your newsreader. Visit
<a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free.
</div>
<h1>
<div class="rss-icon">
<svg
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731"
xml:space="preserve">
<g>
<rect
x="0"
y="0"
style="fill: #f78422"
width="455.731"
height="455.731"
/>
<g>
<path
style="fill: #ffffff"
d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"
/>
<path
style="fill: #ffffff"
d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"
/>
<circle
style="fill: #ffffff"
cx="109.833"
cy="346.26"
r="46.088"
/>
</g>
</g>
</svg>
</div>
<xsl:value-of select="rss/channel/title" />
</h1>
<p class="blog-description">
<xsl:value-of select="rss/channel/description" />
</p>
</div>
<h2>Recent Posts</h2>
<div class="blog-posts">
<xsl:for-each select="rss/channel/item">
<div class="blog-post">
<h3><a href="{link}"><xsl:value-of select="title" /></a></h3>
<div class="blog-post-date">
Published on <xsl:value-of select="substring(pubDate,0,17)" />
</div>
<div class="blog-post-description">
<xsl:value-of select="description" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -59,6 +59,7 @@
"node": ">=18.0"
},
"devDependencies": {
"@total-typescript/shoehorn": "^0.1.2"
"@total-typescript/shoehorn": "^0.1.2",
"tree-node-cli": "^1.6.0"
}
}

View file

@ -7,3 +7,4 @@ slorber:
twitter: sebastienlorber
x: https://x.com/sebastienlorber
github: slorber
page: true

View file

@ -0,0 +1,76 @@
/**
* 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.
*/
* {
color: #0d1137;
}
main {
flex: 1 0 auto;
width: 100%;
margin: 4rem auto;
padding: 1.5 rem;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 3rem 0;
padding: 2rem 3rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #e52165;
}
.rss-icon {
height: 3.8rem;
width: 3.8rem;
margin-right: 1rem;
}
.flex {
display: flex;
}
.items-start {
align-items: flex-start;
}
.pb-7 {
padding-bottom: 3rem;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.8rem;
line-height: 1;
font-weight: 800;
margin-bottom: 4rem;
}
h2 {
font-size: 3rem;
line-height: 1.2;
font-weight: 700;
margin-bottom: 3rem;
}
h2:not(:first-child) {
margin-top: 5.8rem;
}
.italic {
font-style: italic;
}

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Atom Feed | <xsl:value-of select="atom:feed/atom:title" /></title>
<link rel="stylesheet" href="custom-atom.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an Atom feed</strong>. Subscribe by copying the URL from the address
bar into your newsreader. Visit <a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free. </div>
<h1 class="flex items-start">
<div class="rss-icon">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731" xml:space="preserve">
<g>
<rect x="0" y="0" style="fill:#F78422;" width="455.731" height="455.731"/>
<g>
<path style="fill:#FFFFFF;" d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"/>
<path style="fill:#FFFFFF;" d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"/>
<circle style="fill:#FFFFFF;" cx="109.833" cy="346.26" r="46.088"/>
</g>
</g>
</svg>
</div>
Custom Atom Feed Preview </h1>
<h2>
<xsl:value-of select="atom:feed/atom:title" />
</h2>
<p>Description: <xsl:value-of select="atom:feed/atom:subtitle" /></p>
</div>
<h2>Recent Posts</h2>
<div class="postsList">
<xsl:for-each select="atom:feed/atom:entry">
<div class="pb-7">
<a href="{atom:link[@rel='alternate']/@href}">
<xsl:value-of select="atom:title" />
</a>
<div class="text-2 text-offset"> Published on <xsl:value-of
select="substring(atom:updated, 0, 17)" />
</div>
<div class="text-2 text-offset italic">
<xsl:value-of select="atom:summary" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,76 @@
/**
* 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.
*/
* {
color: #0d1137;
}
main {
flex: 1 0 auto;
width: 100%;
margin: 4rem auto;
padding: 1.5 rem;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 3rem 0;
padding: 2rem 3rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #e52165;
}
.rss-icon {
height: 3.8rem;
width: 3.8rem;
margin-right: 1rem;
}
.flex {
display: flex;
}
.items-start {
align-items: flex-start;
}
.pb-7 {
padding-bottom: 3rem;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.8rem;
line-height: 1;
font-weight: 800;
margin-bottom: 4rem;
}
h2 {
font-size: 3rem;
line-height: 1.2;
font-weight: 700;
margin-bottom: 3rem;
}
h2:not(:first-child) {
margin-top: 5.8rem;
}
.italic {
font-style: italic;
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>RSS Feed | <xsl:value-of select="rss/channel/title" /></title>
<link rel="stylesheet" href="custom-rss.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an RSS feed</strong>. Subscribe by copying the URL from the address
bar into your newsreader. Visit <a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free. </div>
<h1 class="flex items-start">
<div class="rss-icon">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731" xml:space="preserve">
<g>
<rect x="0" y="0" style="fill:#F78422;" width="455.731" height="455.731"/>
<g>
<path style="fill:#FFFFFF;" d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"/>
<path style="fill:#FFFFFF;" d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"/>
<circle style="fill:#FFFFFF;" cx="109.833" cy="346.26" r="46.088"/>
</g>
</g>
</svg>
</div>
Custom RSS Feed Preview </h1>
<h2>
<xsl:value-of select="rss/channel/title" />
</h2>
<p>Description: <xsl:value-of select="rss/channel/description" /></p>
</div>
<h2>Recent Posts</h2>
<div class="postsList">
<xsl:for-each select="rss/channel/item">
<div class="pb-7">
<a href="{link}">
<xsl:value-of select="title" />
</a>
<div class="text-2 text-offset"> Published on <xsl:value-of
select="substring(pubDate,0,17)" />
</div>
<div class="text-2 text-offset italic">
<xsl:value-of
select="description" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,75 @@
/**
* 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.
*/
main {
flex: 1 0 auto;
width: 100%;
margin: 2rem auto;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 2rem 0;
padding: 1.6rem 2.4rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #edf5ff;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.4rem;
font-weight: 800;
margin-bottom: 2rem;
display: flex;
align-items: center;
}
h1 .rss-icon {
height: 3.2rem;
width: 3.2rem;
margin-right: 1rem;
}
h2 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
h3 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.1rem;
}
.blog-description {
font-size: 1.4rem;
margin-bottom: 0.6rem;
}
.blog-post-date {
font-size: 1rem;
line-height: 1.4rem;
font-style: italic;
color: #797b7e;
}
.blog-post-description {
font-size: 1rem;
line-height: 1.4rem;
color: #434349;
}

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet
version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Atom Feed | <xsl:value-of
select="atom:feed/atom:title"
/></title>
<link rel="stylesheet" href="atom.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an Atom feed</strong>. Subscribe by copying the URL
from the address bar into your newsreader. Visit
<a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free.
</div>
<h1>
<div class="rss-icon">
<svg
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731"
xml:space="preserve">
<g>
<rect
x="0"
y="0"
style="fill: #f78422"
width="455.731"
height="455.731"
/>
<g>
<path
style="fill: #ffffff"
d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"
/>
<path
style="fill: #ffffff"
d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"
/>
<circle
style="fill: #ffffff"
cx="109.833"
cy="346.26"
r="46.088"
/>
</g>
</g>
</svg>
</div>
<xsl:value-of select="atom:feed/atom:title" />
</h1>
<p class="blog-description">
<xsl:value-of select="atom:feed/atom:subtitle" />
</p>
</div>
<h2>Recent Posts</h2>
<div class="blog-posts">
<xsl:for-each select="atom:feed/atom:entry">
<div class="blog-post">
<h3><a href="{atom:link[@rel='alternate']/@href}"><xsl:value-of
select="atom:title"
/></a></h3>
<div class="blog-post-date">
Published on <xsl:value-of
select="substring(atom:updated,0,11)"
/>
</div>
<div class="blog-post-description">
<xsl:value-of select="atom:summary" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,76 @@
/**
* 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.
*/
* {
color: #0d1137;
}
main {
flex: 1 0 auto;
width: 100%;
margin: 4rem auto;
padding: 1.5 rem;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 3rem 0;
padding: 2rem 3rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #e52165;
}
.rss-icon {
height: 3.8rem;
width: 3.8rem;
margin-right: 1rem;
}
.flex {
display: flex;
}
.items-start {
align-items: flex-start;
}
.pb-7 {
padding-bottom: 3rem;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.8rem;
line-height: 1;
font-weight: 800;
margin-bottom: 4rem;
}
h2 {
font-size: 3rem;
line-height: 1.2;
font-weight: 700;
margin-bottom: 3rem;
}
h2:not(:first-child) {
margin-top: 5.8rem;
}
.italic {
font-style: italic;
}

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>Atom Feed | <xsl:value-of select="atom:feed/atom:title" /></title>
<link rel="stylesheet" href="custom-atom.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an Atom feed</strong>. Subscribe by copying the URL from the address
bar into your newsreader. Visit <a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free. </div>
<h1 class="flex items-start">
<div class="rss-icon">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731" xml:space="preserve">
<g>
<rect x="0" y="0" style="fill:#F78422;" width="455.731" height="455.731"/>
<g>
<path style="fill:#FFFFFF;" d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"/>
<path style="fill:#FFFFFF;" d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"/>
<circle style="fill:#FFFFFF;" cx="109.833" cy="346.26" r="46.088"/>
</g>
</g>
</svg>
</div>
Custom Atom Feed Preview </h1>
<h2>
<xsl:value-of select="atom:feed/atom:title" />
</h2>
<p>Description: <xsl:value-of select="atom:feed/atom:subtitle" /></p>
</div>
<h2>Recent Posts</h2>
<div class="postsList">
<xsl:for-each select="atom:feed/atom:entry">
<div class="pb-7">
<a href="{atom:link[@rel='alternate']/@href}">
<xsl:value-of select="atom:title" />
</a>
<div class="text-2 text-offset"> Published on <xsl:value-of
select="substring(atom:updated, 0, 17)" />
</div>
<div class="text-2 text-offset italic">
<xsl:value-of select="atom:summary" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,76 @@
/**
* 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.
*/
* {
color: #0d1137;
}
main {
flex: 1 0 auto;
width: 100%;
margin: 4rem auto;
padding: 1.5 rem;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 3rem 0;
padding: 2rem 3rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #e52165;
}
.rss-icon {
height: 3.8rem;
width: 3.8rem;
margin-right: 1rem;
}
.flex {
display: flex;
}
.items-start {
align-items: flex-start;
}
.pb-7 {
padding-bottom: 3rem;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.8rem;
line-height: 1;
font-weight: 800;
margin-bottom: 4rem;
}
h2 {
font-size: 3rem;
line-height: 1.2;
font-weight: 700;
margin-bottom: 3rem;
}
h2:not(:first-child) {
margin-top: 5.8rem;
}
.italic {
font-style: italic;
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>RSS Feed | <xsl:value-of select="rss/channel/title" /></title>
<link rel="stylesheet" href="custom-rss.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an RSS feed</strong>. Subscribe by copying the URL from the address
bar into your newsreader. Visit <a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free. </div>
<h1 class="flex items-start">
<div class="rss-icon">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731" xml:space="preserve">
<g>
<rect x="0" y="0" style="fill:#F78422;" width="455.731" height="455.731"/>
<g>
<path style="fill:#FFFFFF;" d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"/>
<path style="fill:#FFFFFF;" d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"/>
<circle style="fill:#FFFFFF;" cx="109.833" cy="346.26" r="46.088"/>
</g>
</g>
</svg>
</div>
Custom RSS Feed Preview </h1>
<h2>
<xsl:value-of select="rss/channel/title" />
</h2>
<p>Description: <xsl:value-of select="rss/channel/description" /></p>
</div>
<h2>Recent Posts</h2>
<div class="postsList">
<xsl:for-each select="rss/channel/item">
<div class="pb-7">
<a href="{link}">
<xsl:value-of select="title" />
</a>
<div class="text-2 text-offset"> Published on <xsl:value-of
select="substring(pubDate,0,17)" />
</div>
<div class="text-2 text-offset italic">
<xsl:value-of
select="description" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -0,0 +1,75 @@
/**
* 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.
*/
main {
flex: 1 0 auto;
width: 100%;
margin: 2rem auto;
max-width: 800px;
/* stylelint-disable-next-line font-family-name-quotes */
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell;
}
.info {
display: block;
margin: 2rem 0;
padding: 1.6rem 2.4rem;
border: 1px solid dodgerblue;
border-left-width: 0.5rem;
border-radius: 0.4rem;
background-color: #edf5ff;
}
a {
color: #005aff;
text-decoration: none;
}
h1 {
text-wrap: balance;
font-size: 3.4rem;
font-weight: 800;
margin-bottom: 2rem;
display: flex;
align-items: center;
}
h1 .rss-icon {
height: 3.2rem;
width: 3.2rem;
margin-right: 1rem;
}
h2 {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 0.2rem;
}
h3 {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 0.1rem;
}
.blog-description {
font-size: 1.4rem;
margin-bottom: 0.6rem;
}
.blog-post-date {
font-size: 1rem;
line-height: 1.4rem;
font-style: italic;
color: #797b7e;
}
.blog-post-description {
font-size: 1rem;
line-height: 1.4rem;
color: #434349;
}

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" ?>
<xsl:stylesheet
version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom">
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<title>RSS Feed | <xsl:value-of select="rss/channel/title" /></title>
<link rel="stylesheet" href="rss.css" />
</head>
<body>
<main>
<div class="description">
<div class="info">
<strong>This is an RSS feed</strong>. Subscribe by copying the URL
from the address bar into your newsreader. Visit
<a href="https://aboutfeeds.com/">About Feeds</a> to learn more
and get started. Its free.
</div>
<h1>
<div class="rss-icon">
<svg
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 455.731 455.731"
xml:space="preserve">
<g>
<rect
x="0"
y="0"
style="fill: #f78422"
width="455.731"
height="455.731"
/>
<g>
<path
style="fill: #ffffff"
d="M296.208,159.16C234.445,97.397,152.266,63.382,64.81,63.382v64.348
c70.268,0,136.288,27.321,185.898,76.931c49.609,49.61,76.931,115.63,76.931,185.898h64.348
C391.986,303.103,357.971,220.923,296.208,159.16z"
/>
<path
style="fill: #ffffff"
d="M64.143,172.273v64.348c84.881,0,153.938,69.056,153.938,153.939h64.348
C282.429,270.196,184.507,172.273,64.143,172.273z"
/>
<circle
style="fill: #ffffff"
cx="109.833"
cy="346.26"
r="46.088"
/>
</g>
</g>
</svg>
</div>
<xsl:value-of select="rss/channel/title" />
</h1>
<p class="blog-description">
<xsl:value-of select="rss/channel/description" />
</p>
</div>
<h2>Recent Posts</h2>
<div class="blog-posts">
<xsl:for-each select="rss/channel/item">
<div class="blog-post">
<h3><a href="{link}"><xsl:value-of select="title" /></a></h3>
<div class="blog-post-date">
Published on <xsl:value-of select="substring(pubDate,0,17)" />
</div>
<div class="blog-post-description">
<xsl:value-of select="description" />
</div>
</div>
</xsl:for-each>
</div>
</main>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View file

@ -2,3 +2,5 @@ slorber:
name: Sébastien Lorber (translated)
title: Docusaurus maintainer (translated)
email: lorber.sebastien@gmail.com
page:
permalink: "/slorber-custom-permalink-localized"

View file

@ -25,6 +25,25 @@ exports[`paginateBlogPosts generates a single page 1`] = `
]
`;
exports[`paginateBlogPosts generates pages - 0 blog post 1`] = `
[
{
"items": [],
"metadata": {
"blogDescription": "Blog Description",
"blogTitle": "Blog Title",
"nextPage": undefined,
"page": 1,
"permalink": "/blog",
"postsPerPage": 2,
"previousPage": undefined,
"totalCount": 0,
"totalPages": 1,
},
},
]
`;
exports[`paginateBlogPosts generates pages 1`] = `
[
{

View file

@ -121,7 +121,9 @@ exports[`blog plugin process blog posts load content 2`] = `
"authors": [
{
"imageURL": undefined,
"key": null,
"name": "Sébastien Lorber",
"page": null,
"title": "Docusaurus maintainer",
"url": "https://sebastienlorber.com",
},

View file

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateOptions throws Error in case of invalid feed type 1`] = `""feedOptions.type" does not match any of the allowed types"`;
exports[`validateOptions feed throws Error in case of invalid feed type 1`] = `""feedOptions.type" does not match any of the allowed types"`;
exports[`validateOptions throws Error in case of invalid options 1`] = `""postsPerPage" must be greater than or equal to 1"`;

View file

@ -5,13 +5,13 @@
* LICENSE file in the root directory of this source tree.
*/
import * as path from 'path';
import {
type AuthorsMap,
getAuthorsMap,
getBlogPostAuthors,
validateAuthorsMap,
} from '../authors';
import {fromPartial, type PartialDeep} from '@total-typescript/shoehorn';
import {getBlogPostAuthors, groupBlogPostsByAuthorKey} from '../authors';
import type {AuthorsMap, BlogPost} from '@docusaurus/plugin-content-blog';
function post(partial: PartialDeep<BlogPost>): BlogPost {
return fromPartial(partial);
}
describe('getBlogPostAuthors', () => {
it('can read no authors', () => {
@ -42,7 +42,15 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/',
}),
).toEqual([{name: 'Sébastien Lorber'}]);
).toEqual([
{
name: 'Sébastien Lorber',
imageURL: undefined,
key: null,
page: null,
title: undefined,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -51,7 +59,15 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/',
}),
).toEqual([{title: 'maintainer'}]);
).toEqual([
{
title: 'maintainer',
imageURL: undefined,
key: null,
name: undefined,
page: null,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -60,7 +76,14 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/',
}),
).toEqual([{imageURL: 'https://github.com/slorber.png'}]);
).toEqual([
{
imageURL: 'https://github.com/slorber.png',
key: null,
name: undefined,
page: null,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -69,7 +92,14 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/',
}),
).toEqual([{imageURL: '/img/slorber.png'}]);
).toEqual([
{
imageURL: '/img/slorber.png',
key: null,
name: undefined,
page: null,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -78,7 +108,15 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/baseURL',
}),
).toEqual([{imageURL: '/baseURL/img/slorber.png'}]);
).toEqual([
{
imageURL: '/baseURL/img/slorber.png',
key: null,
name: undefined,
page: null,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -99,6 +137,8 @@ describe('getBlogPostAuthors', () => {
title: 'maintainer1',
imageURL: 'https://github.com/slorber1.png',
url: 'https://github.com/slorber1',
key: null,
page: null,
},
]);
});
@ -109,10 +149,19 @@ describe('getBlogPostAuthors', () => {
frontMatter: {
authors: 'slorber',
},
authorsMap: {slorber: {name: 'Sébastien Lorber'}},
authorsMap: {
slorber: {name: 'Sébastien Lorber', key: 'slorber', page: null},
},
baseUrl: '/',
}),
).toEqual([{key: 'slorber', name: 'Sébastien Lorber'}]);
).toEqual([
{
key: 'slorber',
name: 'Sébastien Lorber',
imageURL: undefined,
page: null,
},
]);
expect(
getBlogPostAuthors({
frontMatter: {
@ -122,6 +171,8 @@ describe('getBlogPostAuthors', () => {
slorber: {
name: 'Sébastien Lorber',
imageURL: 'https://github.com/slorber.png',
key: 'slorber',
page: null,
},
},
baseUrl: '/',
@ -131,6 +182,7 @@ describe('getBlogPostAuthors', () => {
key: 'slorber',
name: 'Sébastien Lorber',
imageURL: 'https://github.com/slorber.png',
page: null,
},
]);
expect(
@ -142,6 +194,8 @@ describe('getBlogPostAuthors', () => {
slorber: {
name: 'Sébastien Lorber',
imageURL: '/img/slorber.png',
key: 'slorber',
page: null,
},
},
baseUrl: '/',
@ -151,6 +205,7 @@ describe('getBlogPostAuthors', () => {
key: 'slorber',
name: 'Sébastien Lorber',
imageURL: '/img/slorber.png',
page: null,
},
]);
expect(
@ -162,6 +217,8 @@ describe('getBlogPostAuthors', () => {
slorber: {
name: 'Sébastien Lorber',
imageURL: '/img/slorber.png',
key: 'slorber',
page: null,
},
},
baseUrl: '/baseUrl',
@ -171,6 +228,7 @@ describe('getBlogPostAuthors', () => {
key: 'slorber',
name: 'Sébastien Lorber',
imageURL: '/baseUrl/img/slorber.png',
page: null,
},
]);
});
@ -182,14 +240,31 @@ describe('getBlogPostAuthors', () => {
authors: ['slorber', 'yangshun'],
},
authorsMap: {
slorber: {name: 'Sébastien Lorber', title: 'maintainer'},
yangshun: {name: 'Yangshun Tay'},
slorber: {
name: 'Sébastien Lorber',
title: 'maintainer',
key: 'slorber',
page: null,
},
yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null},
},
baseUrl: '/',
}),
).toEqual([
{key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'},
{key: 'yangshun', name: 'Yangshun Tay'},
{
key: 'slorber',
name: 'Sébastien Lorber',
title: 'maintainer',
imageURL: undefined,
page: null,
},
{
key: 'yangshun',
name: 'Yangshun Tay',
imageURL: undefined,
page: null,
},
]);
});
@ -202,7 +277,15 @@ describe('getBlogPostAuthors', () => {
authorsMap: undefined,
baseUrl: '/',
}),
).toEqual([{name: 'Sébastien Lorber', title: 'maintainer'}]);
).toEqual([
{
name: 'Sébastien Lorber',
title: 'maintainer',
imageURL: undefined,
key: null,
page: null,
},
]);
});
it('can read authors Author[]', () => {
@ -218,8 +301,14 @@ describe('getBlogPostAuthors', () => {
baseUrl: '/',
}),
).toEqual([
{name: 'Sébastien Lorber', title: 'maintainer'},
{name: 'Yangshun Tay'},
{
name: 'Sébastien Lorber',
title: 'maintainer',
imageURL: undefined,
key: null,
page: null,
},
{name: 'Yangshun Tay', imageURL: undefined, key: null, page: null},
]);
});
@ -238,66 +327,38 @@ describe('getBlogPostAuthors', () => {
],
},
authorsMap: {
slorber: {name: 'Sébastien Lorber', title: 'maintainer'},
yangshun: {name: 'Yangshun Tay', title: 'Yangshun title original'},
slorber: {
name: 'Sébastien Lorber',
title: 'maintainer',
key: 'slorber',
page: null,
},
yangshun: {
name: 'Yangshun Tay',
title: 'Yangshun title original',
key: 'yangshun',
page: null,
},
},
baseUrl: '/',
}),
).toEqual([
{key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'},
{
key: 'slorber',
name: 'Sébastien Lorber',
title: 'maintainer',
imageURL: undefined,
page: null,
},
{
key: 'yangshun',
name: 'Yangshun Tay',
title: 'Yangshun title local override',
extra: 42,
imageURL: undefined,
page: null,
},
{name: 'Alexey'},
]);
});
it('can normalize inline authors', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: [
{
name: 'Seb1',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'sebastienlorber',
github: 'slorber',
},
},
{
name: 'Seb2',
socials: {
x: 'sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
],
},
authorsMap: {},
baseUrl: '/',
}),
).toEqual([
{
name: 'Seb1',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
{
name: 'Seb2',
socials: {
x: 'https://x.com/sebastienlorber',
twitter: 'https://twitter.com/sebastienlorber',
github: 'https://github.com/slorber',
},
},
{name: 'Alexey', imageURL: undefined, key: null, page: null},
]);
});
@ -339,8 +400,8 @@ describe('getBlogPostAuthors', () => {
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null},
jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null},
},
baseUrl: '/',
}),
@ -360,8 +421,8 @@ describe('getBlogPostAuthors', () => {
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null},
jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null},
},
baseUrl: '/',
}),
@ -381,8 +442,8 @@ describe('getBlogPostAuthors', () => {
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null},
jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null},
},
baseUrl: '/',
}),
@ -415,7 +476,9 @@ describe('getBlogPostAuthors', () => {
authors: [{key: 'slorber'}],
author_title: 'legacy title',
},
authorsMap: {slorber: {name: 'Sébastien Lorber'}},
authorsMap: {
slorber: {name: 'Sébastien Lorber', key: 'slorber', page: null},
},
baseUrl: '/',
}),
).toThrowErrorMatchingInlineSnapshot(`
@ -425,241 +488,37 @@ describe('getBlogPostAuthors', () => {
});
});
describe('getAuthorsMap', () => {
const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles');
const contentPaths = {
contentPathLocalized: fixturesDir,
contentPath: fixturesDir,
};
it('getAuthorsMap can read yml file', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.yml',
}),
).resolves.toBeDefined();
describe('groupBlogPostsByAuthorKey', () => {
const authorsMap: AuthorsMap = fromPartial({
ozaki: {},
slorber: {},
keyWithNoPost: {},
});
it('getAuthorsMap can read json file', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.json',
}),
).resolves.toBeDefined();
it('can group blog posts', () => {
const post1 = post({metadata: {authors: [{key: 'ozaki'}]}});
const post2 = post({
metadata: {authors: [{key: 'slorber'}, {key: 'ozaki'}]},
});
const post3 = post({metadata: {authors: [{key: 'slorber'}]}});
const post4 = post({
metadata: {authors: [{name: 'Inline author 1'}, {key: 'slorber'}]},
});
const post5 = post({
metadata: {authors: [{name: 'Inline author 2'}]},
});
const post6 = post({
metadata: {authors: [{key: 'unknownKey'}]},
});
it('getAuthorsMap can return undefined if yaml file not found', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors_does_not_exist.yml',
}),
).resolves.toBeUndefined();
});
const blogPosts = [post1, post2, post3, post4, post5, post6];
describe('getAuthorsMap returns normalized', () => {
it('socials', async () => {
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.yml',
});
expect(authorsMap.slorber.socials).toMatchInlineSnapshot(`
{
"stackoverflow": "https://stackoverflow.com/users/82609",
"twitter": "https://twitter.com/sebastienlorber",
"x": "https://x.com/sebastienlorber",
}
`);
expect(authorsMap.JMarcey.socials).toMatchInlineSnapshot(`
{
"stackoverflow": "https://stackoverflow.com/users/102705/Joel-Marcey",
"twitter": "https://twitter.com/JoelMarcey",
"x": "https://x.com/JoelMarcey",
}
`);
expect(groupBlogPostsByAuthorKey({authorsMap, blogPosts})).toEqual({
ozaki: [post1, post2],
slorber: [post2, post3, post4],
keyWithNoPost: [],
// We don't care about this edge case, it doesn't happen in practice
unknownKey: undefined,
});
});
});
describe('validateAuthorsMap', () => {
it('accept valid authors map', () => {
const authorsMap: AuthorsMap = {
slorber: {
name: 'Sébastien Lorber',
title: 'maintainer',
url: 'https://sebastienlorber.com',
imageURL: 'https://github.com/slorber.png',
},
yangshun: {
name: 'Yangshun Tay',
imageURL: 'https://github.com/yangshun.png',
randomField: 42,
},
jmarcey: {
name: 'Joel',
title: 'creator of Docusaurus',
hello: new Date(),
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
it('rename snake case image_url to camelCase imageURL', () => {
const authorsMap: AuthorsMap = {
slorber: {
name: 'Sébastien Lorber',
image_url: 'https://github.com/slorber.png',
},
};
expect(validateAuthorsMap(authorsMap)).toEqual({
slorber: {
name: 'Sébastien Lorber',
imageURL: 'https://github.com/slorber.png',
},
});
});
it('accept author with only image', () => {
const authorsMap: AuthorsMap = {
slorber: {
imageURL: 'https://github.com/slorber.png',
url: 'https://github.com/slorber',
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
it('reject author without name or image', () => {
const authorsMap: AuthorsMap = {
slorber: {
title: 'foo',
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" must contain at least one of [name, imageURL]"`,
);
});
it('reject undefined author', () => {
expect(() =>
validateAuthorsMap({
slorber: undefined,
}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" cannot be undefined. It should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject null author', () => {
expect(() =>
validateAuthorsMap({
slorber: null,
}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject array author', () => {
expect(() =>
validateAuthorsMap({slorber: []}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject array content', () => {
expect(() => validateAuthorsMap([])).toThrowErrorMatchingInlineSnapshot(
`"The authors map file should contain an object where each entry contains an author key and the corresponding author's data."`,
);
});
it('reject flat author', () => {
expect(() =>
validateAuthorsMap({name: 'Sébastien'}),
).toThrowErrorMatchingInlineSnapshot(
`""name" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject non-map author', () => {
const authorsMap: AuthorsMap = {
// @ts-expect-error: for tests
slorber: [],
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
});
describe('authors socials', () => {
it('valid known author map socials', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
twitter: 'ozakione',
github: 'ozakione',
},
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
it('throw socials that are not strings', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: 42,
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('throw socials that are objects', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: {link: 'ozakione'},
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('valid unknown author map socials', () => {
const authorsMap: AuthorsMap = {
ozaki: {
name: 'ozaki',
socials: {
random: 'ozakione',
},
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
});

View file

@ -0,0 +1,307 @@
/**
* 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 {
type AuthorsMapInput,
checkAuthorsMapPermalinkCollisions,
getAuthorsMap,
validateAuthorsMap,
validateAuthorsMapInput,
} from '../authorsMap';
import type {AuthorsMap} from '@docusaurus/plugin-content-blog';
describe('checkAuthorsMapPermalinkCollisions', () => {
it('do not throw when permalinks are unique', () => {
const authors: AuthorsMap = {
author1: {
name: 'author1',
key: 'author1',
page: {
permalink: '/author1',
},
},
author2: {
name: 'author2',
key: 'author2',
page: {
permalink: '/author2',
},
},
};
expect(() => {
checkAuthorsMapPermalinkCollisions(authors);
}).not.toThrow();
});
it('throw when permalinks collide', () => {
const authors: AuthorsMap = {
author1: {
name: 'author1',
key: 'author1',
page: {
permalink: '/author1',
},
},
author2: {
name: 'author1',
key: 'author1',
page: {
permalink: '/author1',
},
},
};
expect(() => {
checkAuthorsMapPermalinkCollisions(authors);
}).toThrowErrorMatchingInlineSnapshot(`
"The following permalinks are duplicated:
Permalink: /author1
Authors: author1, author1"
`);
});
});
describe('getAuthorsMap', () => {
const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles');
const contentPaths = {
contentPathLocalized: fixturesDir,
contentPath: fixturesDir,
};
it('getAuthorsMap can read yml file', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.yml',
authorsBaseRoutePath: '/authors',
}),
).resolves.toBeDefined();
});
it('getAuthorsMap can read json file', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.json',
authorsBaseRoutePath: '/authors',
}),
).resolves.toBeDefined();
});
it('getAuthorsMap can return undefined if yaml file not found', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors_does_not_exist.yml',
authorsBaseRoutePath: '/authors',
}),
).resolves.toBeUndefined();
});
});
describe('validateAuthorsMapInput', () => {
it('accept valid authors map', () => {
const authorsMap: AuthorsMapInput = {
slorber: {
name: 'Sébastien Lorber',
title: 'maintainer',
url: 'https://sebastienlorber.com',
imageURL: 'https://github.com/slorber.png',
key: 'slorber',
page: false,
},
yangshun: {
name: 'Yangshun Tay',
imageURL: 'https://github.com/yangshun.png',
randomField: 42,
key: 'yangshun',
page: false,
},
jmarcey: {
name: 'Joel',
title: 'creator of Docusaurus',
hello: new Date(),
key: 'jmarcey',
page: false,
},
};
expect(validateAuthorsMapInput(authorsMap)).toEqual(authorsMap);
});
it('rename snake case image_url to camelCase imageURL', () => {
const authorsMap: AuthorsMapInput = {
slorber: {
name: 'Sébastien Lorber',
image_url: 'https://github.com/slorber.png',
key: 'slorber',
page: false,
},
};
expect(validateAuthorsMapInput(authorsMap)).toEqual({
slorber: {
name: 'Sébastien Lorber',
imageURL: 'https://github.com/slorber.png',
page: false,
key: 'slorber',
},
});
});
it('accept author with only image', () => {
const authorsMap: AuthorsMapInput = {
slorber: {
imageURL: 'https://github.com/slorber.png',
url: 'https://github.com/slorber',
key: 'slorber',
page: false,
},
};
expect(validateAuthorsMapInput(authorsMap)).toEqual(authorsMap);
});
it('reject author without name or image', () => {
const authorsMap: AuthorsMapInput = {
slorber: {
title: 'foo',
key: 'slorber',
page: false,
},
};
expect(() =>
validateAuthorsMapInput(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" must contain at least one of [name, imageURL]"`,
);
});
it('reject undefined author', () => {
expect(() =>
validateAuthorsMapInput({
slorber: undefined,
}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" cannot be undefined. It should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject null author', () => {
expect(() =>
validateAuthorsMapInput({
slorber: null,
}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject array author', () => {
expect(() =>
validateAuthorsMapInput({slorber: []}),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject array content', () => {
expect(() =>
validateAuthorsMapInput([]),
).toThrowErrorMatchingInlineSnapshot(
`"The authors map file should contain an object where each entry contains an author key and the corresponding author's data."`,
);
});
it('reject flat author', () => {
expect(() =>
validateAuthorsMapInput({name: 'Sébastien'}),
).toThrowErrorMatchingInlineSnapshot(
`""name" should be an author object containing properties like name, title, and imageURL."`,
);
});
it('reject non-map author', () => {
const authorsMap: AuthorsMapInput = {
// @ts-expect-error: intentionally invalid
slorber: [],
};
expect(() =>
validateAuthorsMapInput(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""slorber" should be an author object containing properties like name, title, and imageURL."`,
);
});
});
describe('authors socials', () => {
it('valid known author map socials', () => {
const authorsMap: AuthorsMapInput = {
ozaki: {
name: 'ozaki',
socials: {
twitter: 'ozakione',
github: 'ozakione',
},
key: 'ozaki',
page: false,
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
it('throw socials that are not strings', () => {
const authorsMap: AuthorsMapInput = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: 42,
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('throw socials that are objects', () => {
const authorsMap: AuthorsMapInput = {
ozaki: {
name: 'ozaki',
socials: {
// @ts-expect-error: for tests
twitter: {link: 'ozakione'},
},
},
};
expect(() =>
validateAuthorsMap(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`""ozaki.socials.twitter" must be a string"`,
);
});
it('valid unknown author map socials', () => {
const authorsMap: AuthorsMapInput = {
ozaki: {
name: 'ozaki',
socials: {
random: 'ozakione',
},
key: 'ozaki',
page: false,
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
});

View file

@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
import {jest} from '@jest/globals';
import {reportDuplicateAuthors, reportInlineAuthors} from '../authorsProblems';
import type {Author} from '@docusaurus/plugin-content-blog';
@ -23,9 +22,13 @@ describe('duplicate authors', () => {
const authors: Author[] = [
{
name: 'Sébastien Lorber',
key: null,
page: null,
},
{
name: 'Sébastien Lorber',
key: null,
page: null,
},
];
@ -42,11 +45,13 @@ describe('duplicate authors', () => {
key: 'slorber',
name: 'Sébastien Lorber 1',
title: 'some title',
page: null,
},
{
key: 'slorber',
name: 'Sébastien Lorber 2',
imageURL: '/slorber.png',
page: null,
},
];
@ -56,7 +61,7 @@ describe('duplicate authors', () => {
}),
).toThrowErrorMatchingInlineSnapshot(`
"Duplicate blog post authors were found in blog post "doc.md" front matter:
- {"key":"slorber","name":"Sébastien Lorber 2","imageURL":"/slorber.png"}"
- {"key":"slorber","name":"Sébastien Lorber 2","imageURL":"/slorber.png","page":null}"
`);
});
});
@ -91,10 +96,12 @@ describe('inline authors', () => {
{
key: 'slorber',
name: 'Sébastien Lorber',
page: null,
},
{
key: 'ozaki',
name: 'Clément Couriol',
page: null,
},
];
@ -110,13 +117,15 @@ describe('inline authors', () => {
{
key: 'slorber',
name: 'Sébastien Lorber',
page: null,
},
{name: 'Inline author 1'},
{name: 'Inline author 1', page: null, key: null},
{
key: 'ozaki',
name: 'Clément Couriol',
page: null,
},
{imageURL: '/inline-author2.png'},
{imageURL: '/inline-author2.png', page: null, key: null},
];
expect(() =>
@ -125,8 +134,8 @@ describe('inline authors', () => {
}),
).toThrowErrorMatchingInlineSnapshot(`
"Some blog authors used in "doc.md" are not defined in "authors.yml":
- {"name":"Inline author 1"}
- {"imageURL":"/inline-author2.png"}
- {"name":"Inline author 1","page":null,"key":null}
- {"imageURL":"/inline-author2.png","page":null,"key":null}
Note that we recommend to declare authors once in a "authors.yml" file and reference them by key in blog posts front matter to avoid author info duplication.
But if you want to allow inline blog authors, you can disable this message by setting onInlineAuthors: 'ignore' in your blog plugin options.
@ -134,45 +143,4 @@ describe('inline authors', () => {
"
`);
});
it('warn inline authors', () => {
const authors: Author[] = [
{
key: 'slorber',
name: 'Sébastien Lorber',
},
{name: 'Inline author 1'},
{
key: 'ozaki',
name: 'Clément Couriol',
},
{imageURL: '/inline-author2.png'},
];
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
expect(() =>
testReport({
authors,
options: {
onInlineAuthors: 'warn',
},
}),
).not.toThrow();
expect(consoleMock).toHaveBeenCalledTimes(1);
expect(consoleMock.mock.calls[0]).toMatchInlineSnapshot(`
[
"[WARNING] Some blog authors used in "doc.md" are not defined in "authors.yml":
- {"name":"Inline author 1"}
- {"imageURL":"/inline-author2.png"}
Note that we recommend to declare authors once in a "authors.yml" file and reference them by key in blog posts front matter to avoid author info duplication.
But if you want to allow inline blog authors, you can disable this message by setting onInlineAuthors: 'ignore' in your blog plugin options.
More info at https://docusaurus.io/docs/blog
",
]
`);
});
});

View file

@ -54,6 +54,24 @@ describe('paginateBlogPosts', () => {
).toMatchSnapshot();
});
it('generates pages - 0 blog post', () => {
const pages = paginateBlogPosts({
blogPosts: [],
basePageUrl: '/blog',
blogTitle: 'Blog Title',
blogDescription: 'Blog Description',
postsPerPageOption: 2,
pageBasePath: 'page',
});
// As part ot https://github.com/facebook/docusaurus/pull/10216
// it was decided that authors with "page: true" that haven't written any
// blog posts yet should still have a dedicated author page
// For this purpose, we generate an empty first page
expect(pages).toHaveLength(1);
expect(pages[0]!.items).toHaveLength(0);
expect(pages).toMatchSnapshot();
});
it('generates pages at blog root', () => {
expect(
paginateBlogPosts({

View file

@ -10,12 +10,15 @@ import path from 'path';
import fs from 'fs-extra';
import {DEFAULT_PARSE_FRONT_MATTER} from '@docusaurus/utils';
import {fromPartial} from '@total-typescript/shoehorn';
import {DEFAULT_OPTIONS} from '../options';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import tree from 'tree-node-cli';
import {DEFAULT_OPTIONS, validateOptions} from '../options';
import {generateBlogPosts} from '../blogUtils';
import {createBlogFeedFiles} from '../feed';
import type {LoadContext, I18n} from '@docusaurus/types';
import {getAuthorsMap} from '../authorsMap';
import type {LoadContext, I18n, Validate} from '@docusaurus/types';
import type {BlogContentPaths} from '../types';
import type {PluginOptions} from '@docusaurus/plugin-content-blog';
import type {Options, PluginOptions} from '@docusaurus/plugin-content-blog';
const DefaultI18N: I18n = {
currentLocale: 'en',
@ -49,12 +52,28 @@ function getBlogContentPaths(siteDir: string): BlogContentPaths {
async function testGenerateFeeds(
context: LoadContext,
options: PluginOptions,
optionsInput: Options,
): Promise<void> {
const options = validateOptions({
validate: normalizePluginOptions as Validate<
Options | undefined,
PluginOptions
>,
options: optionsInput,
});
const contentPaths = getBlogContentPaths(context.siteDir);
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: options.authorsMapPath,
authorsBaseRoutePath: '/authors',
});
const blogPosts = await generateBlogPosts(
getBlogContentPaths(context.siteDir),
contentPaths,
context,
options,
authorsMap,
);
await createBlogFeedFiles({
@ -63,10 +82,11 @@ async function testGenerateFeeds(
siteConfig: context.siteConfig,
outDir: context.outDir,
locale: 'en',
contentPaths,
});
}
describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
const fsMock = jest.spyOn(fs, 'outputFile').mockImplementation(() => {});
it('does not get generated without posts', async () => {
@ -96,13 +116,14 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
} as PluginOptions,
},
);
expect(fsMock).toHaveBeenCalledTimes(0);
@ -139,13 +160,14 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
} as PluginOptions,
},
);
expect(
@ -194,13 +216,14 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
...rest,
});
},
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
} as PluginOptions,
},
);
expect(
@ -240,13 +263,14 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
type: [feedType],
copyright: 'Copyright',
limit: 2,
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
} as PluginOptions,
},
);
expect(
@ -286,13 +310,14 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
} as PluginOptions,
},
);
expect(
@ -300,4 +325,99 @@ describe.each(['atom', 'rss', 'json'])('%s', (feedType) => {
).toMatchSnapshot();
fsMock.mockClear();
});
it('has xslt files for feed', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const outDir = path.join(siteDir, 'build-snap');
const siteConfig = {
title: 'Hello',
baseUrl: '/myBaseUrl/',
url: 'https://docusaurus.io',
favicon: 'image/favicon.ico',
markdown,
};
// Build is quite difficult to mock, so we built the blog beforehand and
// copied the output to the fixture...
await testGenerateFeeds(
fromPartial({
siteDir,
siteConfig,
i18n: DefaultI18N,
outDir,
}),
{
path: 'blog',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
include: DEFAULT_OPTIONS.include,
exclude: DEFAULT_OPTIONS.exclude,
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: true,
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
},
);
expect(tree(path.join(outDir, 'blog'))).toMatchSnapshot('blog tree');
expect(fsMock.mock.calls).toMatchSnapshot();
fsMock.mockClear();
});
it('has custom xslt files for feed', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const outDir = path.join(siteDir, 'build-snap');
const siteConfig = {
title: 'Hello',
baseUrl: '/myBaseUrl/',
url: 'https://docusaurus.io',
favicon: 'image/favicon.ico',
markdown,
};
// Build is quite difficult to mock, so we built the blog beforehand and
// copied the output to the fixture...
await testGenerateFeeds(
fromPartial({
siteDir,
siteConfig,
i18n: DefaultI18N,
outDir,
}),
{
path: 'blog',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
include: DEFAULT_OPTIONS.include,
exclude: DEFAULT_OPTIONS.exclude,
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: {
rss: 'custom-rss.xsl',
atom: 'custom-atom.xsl',
},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
},
);
expect(tree(path.join(outDir, 'blog'))).toMatchSnapshot('blog tree');
expect(fsMock.mock.calls).toMatchSnapshot();
fsMock.mockClear();
});
});

View file

@ -220,12 +220,17 @@ describe('blog plugin', () => {
authors: [
{
name: 'Yangshun Tay (translated)',
imageURL: undefined,
key: null,
page: null,
},
{
email: 'lorber.sebastien@gmail.com',
key: 'slorber',
name: 'Sébastien Lorber (translated)',
title: 'Docusaurus maintainer (translated)',
imageURL: undefined,
page: {permalink: '/blog/authors/slorber-custom-permalink-localized'},
},
],
date: new Date('2018-12-14'),
@ -319,6 +324,8 @@ describe('blog plugin', () => {
title: 'Docusaurus maintainer',
url: 'https://sebastienlorber.com',
imageURL: undefined,
page: null,
key: null,
},
],
prevItem: undefined,

View file

@ -6,8 +6,12 @@
*/
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {validateOptions, DEFAULT_OPTIONS} from '../options';
import type {Options, PluginOptions} from '@docusaurus/plugin-content-blog';
import {validateOptions, DEFAULT_OPTIONS, XSLTBuiltInPaths} from '../options';
import type {
Options,
PluginOptions,
UserFeedOptions,
} from '@docusaurus/plugin-content-blog';
import type {Validate} from '@docusaurus/types';
function testValidate(options?: Options) {
@ -38,7 +42,10 @@ describe('validateOptions', () => {
it('accepts correctly defined user options', () => {
const userOptions: Options = {
...defaultOptions,
feedOptions: {type: 'rss' as const, title: 'myTitle'},
feedOptions: {
type: 'rss' as const,
title: 'myTitle',
},
path: 'not_blog',
routeBasePath: '/myBlog',
postsPerPage: 5,
@ -48,7 +55,13 @@ describe('validateOptions', () => {
};
expect(testValidate(userOptions)).toEqual({
...userOptions,
feedOptions: {type: ['rss'], title: 'myTitle', copyright: '', limit: 20},
feedOptions: {
type: ['rss'],
title: 'myTitle',
copyright: '',
limit: 20,
xslt: {rss: null, atom: null},
},
});
});
@ -79,6 +92,7 @@ describe('validateOptions', () => {
).toThrowErrorMatchingSnapshot();
});
describe('feed', () => {
it('throws Error in case of invalid feed type', () => {
expect(() =>
testValidate({
@ -97,18 +111,27 @@ describe('validateOptions', () => {
}),
).toEqual({
...defaultOptions,
feedOptions: {type: ['rss', 'atom', 'json'], copyright: '', limit: 20},
feedOptions: {
type: ['rss', 'atom', 'json'],
copyright: '',
limit: 20,
xslt: {rss: null, atom: null},
},
});
});
it('accepts null type and return same', () => {
it('accepts null feed type and return same', () => {
expect(
testValidate({
feedOptions: {type: null},
}),
).toEqual({
...defaultOptions,
feedOptions: {type: null, limit: 20},
feedOptions: {
type: null,
limit: 20,
xslt: {rss: null, atom: null},
},
});
});
@ -132,10 +155,122 @@ describe('validateOptions', () => {
title: 'title',
copyright: '',
limit: 20,
xslt: {rss: null, atom: null},
},
});
});
describe('feed xslt', () => {
function testXSLT(xslt: UserFeedOptions['xslt']) {
return testValidate({feedOptions: {xslt}}).feedOptions.xslt;
}
it('accepts xslt: true', () => {
expect(testXSLT(true)).toEqual({
rss: XSLTBuiltInPaths.rss,
atom: XSLTBuiltInPaths.atom,
});
});
it('accepts xslt: false', () => {
expect(testXSLT(false)).toEqual({
rss: null,
atom: null,
});
});
it('accepts xslt: null', () => {
expect(testXSLT(null)).toEqual({
rss: null,
atom: null,
});
});
it('accepts xslt: undefined', () => {
expect(testXSLT(undefined)).toEqual({
rss: null,
atom: null,
});
});
it('accepts xslt: {rss: true}', () => {
expect(testXSLT({rss: true})).toEqual({
rss: XSLTBuiltInPaths.rss,
atom: null,
});
});
it('accepts xslt: {atom: true}', () => {
expect(testXSLT({atom: true})).toEqual({
rss: null,
atom: XSLTBuiltInPaths.atom,
});
});
it('accepts xslt: {rss: true, atom: true}', () => {
expect(testXSLT({rss: true, atom: true})).toEqual({
rss: XSLTBuiltInPaths.rss,
atom: XSLTBuiltInPaths.atom,
});
});
it('accepts xslt: {rss: "custom-path"}', () => {
expect(testXSLT({rss: 'custom-path'})).toEqual({
rss: 'custom-path',
atom: null,
});
});
it('accepts xslt: {rss: true, atom: "custom-path"}', () => {
expect(testXSLT({rss: true, atom: 'custom-path'})).toEqual({
rss: XSLTBuiltInPaths.rss,
atom: 'custom-path',
});
});
it('accepts xslt: {rss: null, atom: true}', () => {
expect(testXSLT({rss: null, atom: true})).toEqual({
rss: null,
atom: XSLTBuiltInPaths.atom,
});
});
it('accepts xslt: {rss: false, atom: null}', () => {
expect(testXSLT({rss: false, atom: null})).toEqual({
rss: null,
atom: null,
});
});
it('rejects xslt: 42', () => {
// @ts-expect-error: bad type
expect(() => testXSLT(42)).toThrowErrorMatchingInlineSnapshot(
`""feedOptions.xslt" must be one of [object, boolean]"`,
);
});
it('rejects xslt: []', () => {
// @ts-expect-error: bad type
expect(() => testXSLT([])).toThrowErrorMatchingInlineSnapshot(
`""feedOptions.xslt" must be one of [object, boolean]"`,
);
});
it('rejects xslt: {rss: 42}', () => {
// @ts-expect-error: bad type
expect(() => testXSLT({rss: 42})).toThrowErrorMatchingInlineSnapshot(
`""feedOptions.xslt.rss" must be one of [string, boolean]"`,
);
});
it('rejects xslt: {rss: []}', () => {
// @ts-expect-error: bad type
expect(() => testXSLT({rss: 42})).toThrowErrorMatchingInlineSnapshot(
`""feedOptions.xslt.rss" must be one of [string, boolean]"`,
);
});
});
});
it('accepts 0 sidebar count', () => {
const userOptions = {blogSidebarCount: 0};
expect(testValidate(userOptions)).toEqual({

View file

@ -5,83 +5,16 @@
* LICENSE file in the root directory of this source tree.
*/
import * as _ from 'lodash';
import {getDataFileData, normalizeUrl} from '@docusaurus/utils';
import {Joi, URISchema} from '@docusaurus/utils-validation';
import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials';
import type {BlogContentPaths} from './types';
import _ from 'lodash';
import {normalizeUrl} from '@docusaurus/utils';
import type {
Author,
AuthorsMap,
BlogPost,
BlogPostFrontMatter,
BlogPostFrontMatterAuthor,
BlogPostFrontMatterAuthors,
} from '@docusaurus/plugin-content-blog';
export type AuthorsMap = {[authorKey: string]: Author};
const AuthorsMapSchema = Joi.object<AuthorsMap>()
.pattern(
Joi.string(),
Joi.object<Author>({
name: Joi.string(),
url: URISchema,
imageURL: URISchema,
title: Joi.string(),
email: Joi.string(),
socials: AuthorSocialsSchema,
})
.rename('image_url', 'imageURL')
.or('name', 'imageURL')
.unknown()
.required()
.messages({
'object.base':
'{#label} should be an author object containing properties like name, title, and imageURL.',
'any.required':
'{#label} cannot be undefined. It should be an author object containing properties like name, title, and imageURL.',
}),
)
.messages({
'object.base':
"The authors map file should contain an object where each entry contains an author key and the corresponding author's data.",
});
export function validateAuthorsMap(content: unknown): AuthorsMap {
const {error, value} = AuthorsMapSchema.validate(content);
if (error) {
throw error;
}
return value;
}
function normalizeAuthor(author: Author): Author {
return {
...author,
socials: author.socials ? normalizeSocials(author.socials) : undefined,
};
}
function normalizeAuthorsMap(authorsMap: AuthorsMap): AuthorsMap {
return _.mapValues(authorsMap, normalizeAuthor);
}
export async function getAuthorsMap(params: {
authorsMapPath: string;
contentPaths: BlogContentPaths;
}): Promise<AuthorsMap | undefined> {
const authorsMap = await getDataFileData(
{
filePath: params.authorsMapPath,
contentPaths: params.contentPaths,
fileType: 'authors map',
},
// TODO annoying to test: tightly coupled FS reads + validation...
validateAuthorsMap,
);
return authorsMap ? normalizeAuthorsMap(authorsMap) : undefined;
}
type AuthorsParam = {
frontMatter: BlogPostFrontMatter;
authorsMap: AuthorsMap | undefined;
@ -102,6 +35,7 @@ function normalizeImageUrl({
// Legacy v1/early-v2 front matter fields
// We may want to deprecate those in favor of using only frontMatter.authors
// TODO Docusaurus v4: remove this legacy front matter
function getFrontMatterAuthorLegacy({
baseUrl,
frontMatter,
@ -123,38 +57,41 @@ function getFrontMatterAuthorLegacy({
title,
url,
imageURL,
// legacy front matter authors do not have an author key/page
key: null,
page: null,
};
}
return undefined;
}
function normalizeFrontMatterAuthors(
frontMatterAuthors: BlogPostFrontMatterAuthors = [],
): BlogPostFrontMatterAuthor[] {
function normalizeFrontMatterAuthor(
authorInput: string | Author,
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
const {authorsMap, frontMatter, baseUrl} = params;
return normalizeFrontMatterAuthors().map(toAuthor);
function normalizeFrontMatterAuthors(): BlogPostFrontMatterAuthor[] {
if (frontMatter.authors === undefined) {
return [];
}
function normalizeAuthor(
authorInput: string | BlogPostFrontMatterAuthor,
): BlogPostFrontMatterAuthor {
if (typeof authorInput === 'string') {
// Technically, we could allow users to provide an author's name here, but
// we only support keys, otherwise, a typo in a key would fallback to
// We could allow users to provide an author's name here, but we only
// support keys, otherwise, a typo in a key would fall back to
// becoming a name and may end up unnoticed
return {key: authorInput};
}
return authorInput;
}
return Array.isArray(frontMatterAuthors)
? frontMatterAuthors.map(normalizeFrontMatterAuthor)
: [normalizeFrontMatterAuthor(frontMatterAuthors)];
return Array.isArray(frontMatter.authors)
? frontMatter.authors.map(normalizeAuthor)
: [normalizeAuthor(frontMatter.authors)];
}
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
const {authorsMap} = params;
const frontMatterAuthors = normalizeFrontMatterAuthors(
params.frontMatter.authors,
);
function getAuthorsMapAuthor(key: string | undefined): Author | undefined {
if (key) {
if (!authorsMap || Object.keys(authorsMap).length === 0) {
@ -175,36 +112,29 @@ ${Object.keys(authorsMap)
}
function toAuthor(frontMatterAuthor: BlogPostFrontMatterAuthor): Author {
return normalizeAuthor({
const author = {
// Author def from authorsMap can be locally overridden by front matter
...getAuthorsMapAuthor(frontMatterAuthor.key),
...frontMatterAuthor,
});
}
};
return frontMatterAuthors.map(toAuthor);
}
function fixAuthorImageBaseURL(
authors: Author[],
{baseUrl}: {baseUrl: string},
) {
return authors.map((author) => ({
return {
...author,
key: author.key ?? null,
page: author.page ?? null,
imageURL: normalizeImageUrl({imageURL: author.imageURL, baseUrl}),
}));
};
}
}
export function getBlogPostAuthors(params: AuthorsParam): Author[] {
const authorLegacy = getFrontMatterAuthorLegacy(params);
const authors = getFrontMatterAuthors(params);
const updatedAuthors = fixAuthorImageBaseURL(authors, params);
if (authorLegacy) {
// Technically, we could allow mixing legacy/authors front matter, but do we
// really want to?
if (updatedAuthors.length > 0) {
if (authors.length > 0) {
throw new Error(
`To declare blog post authors, use the 'authors' front matter in priority.
Don't mix 'authors' with other existing 'author_*' front matter. Choose one or the other, not both at the same time.`,
@ -213,5 +143,21 @@ Don't mix 'authors' with other existing 'author_*' front matter. Choose one or t
return [authorLegacy];
}
return updatedAuthors;
return authors;
}
/**
* Group blog posts by author key
* Blog posts with only inline authors are ignored
*/
export function groupBlogPostsByAuthorKey({
blogPosts,
authorsMap,
}: {
blogPosts: BlogPost[];
authorsMap: AuthorsMap | undefined;
}): Record<string, BlogPost[]> {
return _.mapValues(authorsMap, (author, key) =>
blogPosts.filter((p) => p.metadata.authors.some((a) => a.key === key)),
);
}

View file

@ -0,0 +1,171 @@
/**
* 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 _ from 'lodash';
import {readDataFile, normalizeUrl} from '@docusaurus/utils';
import {Joi, URISchema} from '@docusaurus/utils-validation';
import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials';
import type {BlogContentPaths} from './types';
import type {
Author,
AuthorAttributes,
AuthorPage,
AuthorsMap,
} from '@docusaurus/plugin-content-blog';
type AuthorInput = AuthorAttributes & {
page?: boolean | AuthorPage;
};
export type AuthorsMapInput = {[authorKey: string]: AuthorInput};
const AuthorPageSchema = Joi.object<AuthorPage>({
permalink: Joi.string().required(),
});
const AuthorsMapInputSchema = Joi.object<AuthorsMapInput>()
.pattern(
Joi.string(),
Joi.object({
name: Joi.string(),
url: URISchema,
imageURL: URISchema,
title: Joi.string(),
email: Joi.string(),
page: Joi.alternatives(Joi.bool(), AuthorPageSchema),
socials: AuthorSocialsSchema,
description: Joi.string(),
})
.rename('image_url', 'imageURL')
.or('name', 'imageURL')
.unknown()
.required()
.messages({
'object.base':
'{#label} should be an author object containing properties like name, title, and imageURL.',
'any.required':
'{#label} cannot be undefined. It should be an author object containing properties like name, title, and imageURL.',
}),
)
.messages({
'object.base':
"The authors map file should contain an object where each entry contains an author key and the corresponding author's data.",
});
export function checkAuthorsMapPermalinkCollisions(
authorsMap: AuthorsMap | undefined,
): void {
if (!authorsMap) {
return;
}
const permalinkCounts = _(authorsMap)
// Filter to keep only authors with a page
.pickBy((author) => !!author.page)
// Group authors by their permalink
.groupBy((author) => author.page?.permalink)
// Filter to keep only permalinks with more than one author
.pickBy((authors) => authors.length > 1)
// Transform the object into an array of [permalink, authors] pairs
.toPairs()
.value();
if (permalinkCounts.length > 0) {
const errorMessage = permalinkCounts
.map(
([permalink, authors]) =>
`Permalink: ${permalink}\nAuthors: ${authors
.map((author) => author.name || 'Unknown')
.join(', ')}`,
)
.join('\n');
throw new Error(
`The following permalinks are duplicated:\n${errorMessage}`,
);
}
}
function normalizeAuthor({
authorsBaseRoutePath,
authorKey,
author,
}: {
authorsBaseRoutePath: string;
authorKey: string;
author: AuthorInput;
}): Author & {key: string} {
function getAuthorPage(): AuthorPage | null {
if (!author.page) {
return null;
}
const slug =
author.page === true ? _.kebabCase(authorKey) : author.page.permalink;
return {
permalink: normalizeUrl([authorsBaseRoutePath, slug]),
};
}
return {
...author,
key: authorKey,
page: getAuthorPage(),
socials: author.socials ? normalizeSocials(author.socials) : undefined,
};
}
function normalizeAuthorsMap({
authorsBaseRoutePath,
authorsMapInput,
}: {
authorsBaseRoutePath: string;
authorsMapInput: AuthorsMapInput;
}): AuthorsMap {
return _.mapValues(authorsMapInput, (author, authorKey) => {
return normalizeAuthor({authorsBaseRoutePath, authorKey, author});
});
}
export function validateAuthorsMapInput(content: unknown): AuthorsMapInput {
const {error, value} = AuthorsMapInputSchema.validate(content);
if (error) {
throw error;
}
return value;
}
async function getAuthorsMapInput(params: {
authorsMapPath: string;
contentPaths: BlogContentPaths;
}): Promise<AuthorsMapInput | undefined> {
const content = await readDataFile({
filePath: params.authorsMapPath,
contentPaths: params.contentPaths,
});
return content ? validateAuthorsMapInput(content) : undefined;
}
export async function getAuthorsMap(params: {
authorsMapPath: string;
authorsBaseRoutePath: string;
contentPaths: BlogContentPaths;
}): Promise<AuthorsMap | undefined> {
const authorsMapInput = await getAuthorsMapInput(params);
if (!authorsMapInput) {
return undefined;
}
const authorsMap = normalizeAuthorsMap({authorsMapInput, ...params});
return authorsMap;
}
export function validateAuthorsMap(content: unknown): AuthorsMapInput {
const {error, value} = AuthorsMapInputSchema.validate(content);
if (error) {
throw error;
}
return value;
}

View file

@ -29,11 +29,12 @@ import {
} from '@docusaurus/utils';
import {getTagsFile} from '@docusaurus/utils-validation';
import {validateBlogPostFrontMatter} from './frontMatter';
import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
import {getBlogPostAuthors} from './authors';
import {reportAuthorsProblems} from './authorsProblems';
import type {TagsFile} from '@docusaurus/utils';
import type {LoadContext, ParseFrontMatter} from '@docusaurus/types';
import type {
AuthorsMap,
PluginOptions,
ReadingTimeFunction,
BlogPost,
@ -64,7 +65,7 @@ export function paginateBlogPosts({
const totalCount = blogPosts.length;
const postsPerPage =
postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption;
const numberOfPages = Math.ceil(totalCount / postsPerPage);
const numberOfPages = Math.max(1, Math.ceil(totalCount / postsPerPage));
const pages: BlogPaginated[] = [];
@ -366,6 +367,7 @@ export async function generateBlogPosts(
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
authorsMap?: AuthorsMap,
): Promise<BlogPost[]> {
const {include, exclude} = options;
@ -378,11 +380,6 @@ export async function generateBlogPosts(
ignore: exclude,
});
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: options.authorsMapPath,
});
const tagsFile = await getTagsFile({contentPaths, tags: options.tags});
async function doProcessBlogSourceFile(blogSourceFile: string) {

View file

@ -7,15 +7,20 @@
import path from 'path';
import fs from 'fs-extra';
import logger from '@docusaurus/logger';
import {Feed, type Author as FeedAuthor} from 'feed';
import * as srcset from 'srcset';
import {normalizeUrl, readOutputHTMLFile} from '@docusaurus/utils';
import {
getDataFilePath,
normalizeUrl,
readOutputHTMLFile,
} from '@docusaurus/utils';
import {
blogPostContainerID,
applyTrailingSlash,
} from '@docusaurus/utils-common';
import {load as cheerioLoad} from 'cheerio';
import logger from '@docusaurus/logger';
import type {BlogContentPaths} from './types';
import type {DocusaurusConfig, HtmlTags, LoadContext} from '@docusaurus/types';
import type {
FeedType,
@ -23,6 +28,8 @@ import type {
Author,
BlogPost,
BlogFeedItem,
FeedOptions,
FeedXSLTOptions,
} from '@docusaurus/plugin-content-blog';
async function generateBlogFeed({
@ -180,32 +187,144 @@ async function defaultCreateFeedItems({
);
}
async function resolveXsltFilePaths({
xsltFilePath,
contentPaths,
}: {
xsltFilePath: string;
contentPaths: BlogContentPaths;
}) {
const xsltAbsolutePath: string = path.isAbsolute(xsltFilePath)
? xsltFilePath
: (await getDataFilePath({filePath: xsltFilePath, contentPaths})) ??
path.resolve(contentPaths.contentPath, xsltFilePath);
if (!(await fs.pathExists(xsltAbsolutePath))) {
throw new Error(
logger.interpolate`Blog feed XSLT file not found at path=${path.relative(
process.cwd(),
xsltAbsolutePath,
)}`,
);
}
const parsedPath = path.parse(xsltAbsolutePath);
const cssAbsolutePath = path.resolve(
parsedPath.dir,
`${parsedPath.name}.css`,
);
if (!(await fs.pathExists(xsltAbsolutePath))) {
throw new Error(
logger.interpolate`Blog feed XSLT file was found at path=${path.relative(
process.cwd(),
xsltAbsolutePath,
)}
But its expected co-located CSS file could not be found at path=${path.relative(
process.cwd(),
cssAbsolutePath,
)}
If you want to provide a custom XSLT file, you must provide a CSS file with the exact same name.`,
);
}
return {xsltAbsolutePath, cssAbsolutePath};
}
async function generateXsltFiles({
xsltFilePath,
generatePath,
contentPaths,
}: {
xsltFilePath: string;
generatePath: string;
contentPaths: BlogContentPaths;
}) {
const {xsltAbsolutePath, cssAbsolutePath} = await resolveXsltFilePaths({
xsltFilePath,
contentPaths,
});
const xsltOutputPath = path.join(
generatePath,
path.basename(xsltAbsolutePath),
);
const cssOutputPath = path.join(generatePath, path.basename(cssAbsolutePath));
await fs.copy(xsltAbsolutePath, xsltOutputPath);
await fs.copy(cssAbsolutePath, cssOutputPath);
}
// This modifies the XML feed content to add a relative href to the XSLT file
// Good enough for now: we probably don't need a full XML parser just for that
// See also https://darekkay.com/blog/rss-styling/
function injectXslt({
feedContent,
xsltFilePath,
}: {
feedContent: string;
xsltFilePath: string;
}) {
return feedContent.replace(
'<?xml version="1.0" encoding="utf-8"?>',
`<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="${path.basename(
xsltFilePath,
)}"?>`,
);
}
const FeedConfigs: Record<
FeedType,
{
outputFileName: string;
getContent: (feed: Feed) => string;
getXsltFilePath: (xslt: FeedXSLTOptions) => string | null;
}
> = {
rss: {
outputFileName: 'rss.xml',
getContent: (feed) => feed.rss2(),
getXsltFilePath: (xslt) => xslt.rss,
},
atom: {
outputFileName: 'atom.xml',
getContent: (feed) => feed.atom1(),
getXsltFilePath: (xslt) => xslt.atom,
},
json: {
outputFileName: 'feed.json',
getContent: (feed) => feed.json1(),
getXsltFilePath: () => null,
},
};
async function createBlogFeedFile({
feed,
feedType,
generatePath,
feedOptions,
contentPaths,
}: {
feed: Feed;
feedType: FeedType;
generatePath: string;
feedOptions: FeedOptions;
contentPaths: BlogContentPaths;
}) {
const [feedContent, feedPath] = (() => {
switch (feedType) {
case 'rss':
return [feed.rss2(), 'rss.xml'];
case 'json':
return [feed.json1(), 'feed.json'];
case 'atom':
return [feed.atom1(), 'atom.xml'];
default:
throw new Error(`Feed type ${feedType} not supported.`);
}
})();
try {
await fs.outputFile(path.join(generatePath, feedPath), feedContent);
const feedConfig = FeedConfigs[feedType];
let feedContent = feedConfig.getContent(feed);
const xsltFilePath = feedConfig.getXsltFilePath(feedOptions.xslt);
if (xsltFilePath) {
await generateXsltFiles({xsltFilePath, contentPaths, generatePath});
feedContent = injectXslt({feedContent, xsltFilePath});
}
const outputPath = path.join(generatePath, feedConfig.outputFileName);
await fs.outputFile(outputPath, feedContent);
} catch (err) {
logger.error(`Generating ${feedType} feed failed.`);
throw err;
throw new Error(`Generating ${feedType} feed failed.`, {
cause: err as Error,
});
}
}
@ -222,12 +341,14 @@ export async function createBlogFeedFiles({
siteConfig,
outDir,
locale,
contentPaths,
}: {
blogPosts: BlogPost[];
options: PluginOptions;
siteConfig: DocusaurusConfig;
outDir: string;
locale: string;
contentPaths: BlogContentPaths;
}): Promise<void> {
const blogPosts = allBlogPosts.filter(shouldBeInFeed);
@ -250,6 +371,8 @@ export async function createBlogFeedFiles({
feed,
feedType,
generatePath: path.join(outDir, options.routeBasePath),
feedOptions: options.feedOptions,
contentPaths,
}),
),
);

View file

@ -34,6 +34,7 @@ import {translateContent, getTranslationFiles} from './translations';
import {createBlogFeedFiles, createFeedHtmlHeadTags} from './feed';
import {createAllRoutes} from './routes';
import {checkAuthorsMapPermalinkCollisions, getAuthorsMap} from './authorsMap';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {
@ -160,11 +161,30 @@ export default async function pluginContentBlog(
blogTitle,
blogSidebarTitle,
pageBasePath,
authorsBasePath,
authorsMapPath,
} = options;
const baseBlogUrl = normalizeUrl([baseUrl, routeBasePath]);
const blogTagsListPath = normalizeUrl([baseBlogUrl, tagsBasePath]);
let blogPosts = await generateBlogPosts(contentPaths, context, options);
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath,
authorsBaseRoutePath: normalizeUrl([
baseUrl,
routeBasePath,
authorsBasePath,
]),
});
checkAuthorsMapPermalinkCollisions(authorsMap);
let blogPosts = await generateBlogPosts(
contentPaths,
context,
options,
authorsMap,
);
blogPosts = await applyProcessBlogPosts({
blogPosts,
processBlogPosts: options.processBlogPosts,
@ -178,6 +198,7 @@ export default async function pluginContentBlog(
blogListPaginated: [],
blogTags: {},
blogTagsListPath,
authorsMap,
};
}
@ -226,6 +247,7 @@ export default async function pluginContentBlog(
blogListPaginated,
blogTags,
blogTagsListPath,
authorsMap,
};
},
@ -366,6 +388,7 @@ export default async function pluginContentBlog(
outDir,
siteConfig,
locale: currentLocale,
contentPaths,
});
},

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import {
Joi,
RemarkPluginsSchema,
@ -19,11 +20,20 @@ import type {
PluginOptions,
Options,
FeedType,
FeedXSLTOptions,
} from '@docusaurus/plugin-content-blog';
import type {OptionValidationContext} from '@docusaurus/types';
export const DEFAULT_OPTIONS: PluginOptions = {
feedOptions: {type: ['rss', 'atom'], copyright: '', limit: 20},
feedOptions: {
type: ['rss', 'atom'],
copyright: '',
limit: 20,
xslt: {
rss: null,
atom: null,
},
},
beforeDefaultRehypePlugins: [],
beforeDefaultRemarkPlugins: [],
admonitions: true,
@ -34,6 +44,8 @@ export const DEFAULT_OPTIONS: PluginOptions = {
showReadingTime: true,
blogTagsPostsComponent: '@theme/BlogTagsPostsPage',
blogTagsListComponent: '@theme/BlogTagsListPage',
blogAuthorsPostsComponent: '@theme/Blog/Pages/BlogAuthorsPostsPage',
blogAuthorsListComponent: '@theme/Blog/Pages/BlogAuthorsListPage',
blogPostComponent: '@theme/BlogPostPage',
blogListComponent: '@theme/BlogListPage',
blogArchiveComponent: '@theme/BlogArchivePage',
@ -58,9 +70,98 @@ export const DEFAULT_OPTIONS: PluginOptions = {
processBlogPosts: async () => undefined,
onInlineTags: 'warn',
tags: undefined,
authorsBasePath: 'authors',
onInlineAuthors: 'warn',
};
export const XSLTBuiltInPaths = {
rss: path.resolve(__dirname, '..', 'assets', 'rss.xsl'),
atom: path.resolve(__dirname, '..', 'assets', 'atom.xsl'),
};
function normalizeXsltOption(
option: string | null | boolean,
type: 'rss' | 'atom',
): string | null {
if (typeof option === 'string') {
return option;
}
if (option === true) {
return XSLTBuiltInPaths[type];
}
return null;
}
function createXSLTFilePathSchema(type: 'atom' | 'rss') {
return Joi.alternatives()
.try(
Joi.string().required(),
Joi.boolean()
.allow(null, () => undefined)
.custom((val) => normalizeXsltOption(val, type)),
)
.optional()
.default(null);
}
const FeedXSLTOptionsSchema = Joi.alternatives()
.try(
Joi.object<FeedXSLTOptions>({
rss: createXSLTFilePathSchema('rss'),
atom: createXSLTFilePathSchema('atom'),
}).required(),
Joi.boolean()
.allow(null, () => undefined)
.custom((val) => ({
rss: normalizeXsltOption(val, 'rss'),
atom: normalizeXsltOption(val, 'atom'),
})),
)
.optional()
.custom((val) => {
if (val === null) {
return {
rss: null,
atom: null,
};
}
return val;
})
.default(DEFAULT_OPTIONS.feedOptions.xslt);
const FeedOptionsSchema = Joi.object({
type: Joi.alternatives()
.try(
Joi.array().items(Joi.string().equal('rss', 'atom', 'json')),
Joi.alternatives().conditional(
Joi.string().equal('all', 'rss', 'atom', 'json'),
{
then: Joi.custom((val: FeedType | 'all') =>
val === 'all' ? ['rss', 'atom', 'json'] : [val],
),
},
),
)
.allow(null)
.default(DEFAULT_OPTIONS.feedOptions.type),
xslt: FeedXSLTOptionsSchema,
title: Joi.string().allow(''),
description: Joi.string().allow(''),
// Only add default value when user actually wants a feed (type is not null)
copyright: Joi.when('type', {
is: Joi.any().valid(null),
then: Joi.string().optional(),
otherwise: Joi.string()
.allow('')
.default(DEFAULT_OPTIONS.feedOptions.copyright),
}),
language: Joi.string(),
createFeedItems: Joi.function(),
limit: Joi.alternatives()
.try(Joi.number(), Joi.valid(null), Joi.valid(false))
.default(DEFAULT_OPTIONS.feedOptions.limit),
}).default(DEFAULT_OPTIONS.feedOptions);
const PluginOptionSchema = Joi.object<PluginOptions>({
path: Joi.string().default(DEFAULT_OPTIONS.path),
archiveBasePath: Joi.string()
@ -82,6 +183,12 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
blogTagsPostsComponent: Joi.string().default(
DEFAULT_OPTIONS.blogTagsPostsComponent,
),
blogAuthorsPostsComponent: Joi.string().default(
DEFAULT_OPTIONS.blogAuthorsPostsComponent,
),
blogAuthorsListComponent: Joi.string().default(
DEFAULT_OPTIONS.blogAuthorsListComponent,
),
blogArchiveComponent: Joi.string().default(
DEFAULT_OPTIONS.blogArchiveComponent,
),
@ -107,37 +214,7 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
beforeDefaultRehypePlugins: RehypePluginsSchema.default(
DEFAULT_OPTIONS.beforeDefaultRehypePlugins,
),
feedOptions: Joi.object({
type: Joi.alternatives()
.try(
Joi.array().items(Joi.string().equal('rss', 'atom', 'json')),
Joi.alternatives().conditional(
Joi.string().equal('all', 'rss', 'atom', 'json'),
{
then: Joi.custom((val: FeedType | 'all') =>
val === 'all' ? ['rss', 'atom', 'json'] : [val],
),
},
),
)
.allow(null)
.default(DEFAULT_OPTIONS.feedOptions.type),
title: Joi.string().allow(''),
description: Joi.string().allow(''),
// Only add default value when user actually wants a feed (type is not null)
copyright: Joi.when('type', {
is: Joi.any().valid(null),
then: Joi.string().optional(),
otherwise: Joi.string()
.allow('')
.default(DEFAULT_OPTIONS.feedOptions.copyright),
}),
language: Joi.string(),
createFeedItems: Joi.function(),
limit: Joi.alternatives()
.try(Joi.number(), Joi.valid(null), Joi.valid(false))
.default(DEFAULT_OPTIONS.feedOptions.limit),
}).default(DEFAULT_OPTIONS.feedOptions),
feedOptions: FeedOptionsSchema,
authorsMapPath: Joi.string().default(DEFAULT_OPTIONS.authorsMapPath),
readingTime: Joi.function().default(() => DEFAULT_OPTIONS.readingTime),
sortPosts: Joi.string()
@ -157,6 +234,9 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
.disallow('')
.allow(null, false)
.default(() => DEFAULT_OPTIONS.tags),
authorsBasePath: Joi.string()
.default(DEFAULT_OPTIONS.authorsBasePath)
.disallow(''),
onInlineAuthors: Joi.string()
.equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_OPTIONS.onInlineAuthors),

View file

@ -22,13 +22,7 @@ declare module '@docusaurus/plugin-content-blog' {
export type Assets = {
/**
* If `metadata.yarn workspace website typecheck
4
yarn workspace v1.22.19yarn workspace website typecheck
4
yarn workspace v1.22.19yarn workspace website typecheck
4
yarn workspace v1.22.19image` is a collocated image path, this entry will be the
* If `metadata.image` is a collocated image path, this entry will be the
* bundler-generated image path. Otherwise, it's empty, and the image URL
* should be accessed through `frontMatter.image`.
*/
@ -66,9 +60,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
[customAuthorSocialPlatform: string]: string;
};
export type Author = {
key?: string; // TODO temporary, need refactor
export type AuthorAttributes = {
/**
* If `name` doesn't exist, an `imageURL` is expected.
*/
@ -98,11 +90,45 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
*/
socials?: AuthorSocials;
/**
* Unknown keys are allowed, so that we can pass custom fields to authors,
* Description of the author.
*/
description?: string;
/**
* Unknown keys are allowed, so that we can pass custom fields to authors.
*/
[customAuthorAttribute: string]: unknown;
};
/**
* Metadata of the author's page, if it exists.
*/
export type AuthorPage = {permalink: string};
/**
* Normalized author metadata.
*/
export type Author = AuthorAttributes & {
/**
* Author key, if the author was loaded from the authors map.
* `null` means the author was declared inline.
*/
key: string | null;
/**
* Metadata of the author's page.
* `null` means the author doesn't have a dedicated author page.
*/
page: AuthorPage | null;
};
/** Authors coming from the AuthorsMap always have a key */
export type AuthorWithKey = Author & {key: string};
/** What the authors list page should know about each author. */
export type AuthorItemProp = AuthorWithKey & {
/** Number of blog posts with this author. */
count: number;
};
/**
* Everything is partial/unnormalized, because front matter is always
* preserved as-is. Default values will be applied when generating metadata
@ -194,7 +220,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
last_update?: FrontMatterLastUpdate;
};
export type BlogPostFrontMatterAuthor = Author & {
export type BlogPostFrontMatterAuthor = AuthorAttributes & {
/**
* Will be normalized into the `imageURL` prop.
*/
@ -289,10 +315,26 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
}) => string | undefined;
export type FeedType = 'rss' | 'atom' | 'json';
export type FeedXSLTOptions = {
/**
* RSS XSLT file path, relative to the blog content folder.
* If null, no XSLT file is used and the feed will be displayed as raw XML.
*/
rss: string | null;
/**
* Atom XSLT file path, relative to the blog content folder.
* If null, no XSLT file is used and the feed will be displayed as raw XML.
*/
atom: string | null;
};
/**
* Normalized feed options used within code.
*/
export type FeedOptions = {
/** Enable feeds xslt stylesheets */
xslt: FeedXSLTOptions;
/** If `null`, no feed is generated. */
type?: FeedType[] | null;
/** Title of generated feed. */
@ -427,6 +469,10 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
blogTagsListComponent: string;
/** Root component of the "posts containing tag" page. */
blogTagsPostsComponent: string;
/** Root component of the authors list page. */
blogAuthorsListComponent: string;
/** Root component of the "posts containing author" page. */
blogAuthorsPostsComponent: string;
/** Root component of the blog archive page. */
blogArchiveComponent: string;
/** Blog page title for better SEO. */
@ -471,10 +517,20 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
* (filter, modify, delete, etc...).
*/
processBlogPosts: ProcessBlogPostsFn;
/* Base path for the authors page */
authorsBasePath: string;
/** The behavior of Docusaurus when it finds inline authors. */
onInlineAuthors: 'ignore' | 'log' | 'warn' | 'throw';
};
export type UserFeedXSLTOptions =
| boolean
| null
| {
rss?: string | boolean | null;
atom?: string | boolean | null;
};
/**
* Feed options, as provided by user config. `type` accepts `all` as shortcut
*/
@ -483,6 +539,8 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
{
/** Type of feed to be generated. Use `null` to disable generation. */
type?: FeedOptions['type'] | 'all' | FeedType;
/** User-provided XSLT config for feeds, un-normalized */
xslt?: UserFeedXSLTOptions;
}
>;
/**
@ -508,17 +566,22 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
items: BlogSidebarItem[];
};
export type AuthorsMap = {[authorKey: string]: AuthorWithKey};
export type BlogContent = {
blogSidebarTitle: string;
blogPosts: BlogPost[];
blogListPaginated: BlogPaginated[];
blogTags: BlogTags;
blogTagsListPath: string;
authorsMap?: AuthorsMap;
};
export type BlogMetadata = {
/** the path to the base of the blog */
blogBasePath: string;
/** the path to the authors list page */
authorsListPath: string;
/** title of the overall blog */
blogTitle: string;
};
@ -679,6 +742,47 @@ declare module '@theme/BlogTagsListPage' {
export default function BlogTagsListPage(props: Props): JSX.Element;
}
declare module '@theme/Blog/Pages/BlogAuthorsListPage' {
import type {
AuthorItemProp,
BlogSidebar,
} from '@docusaurus/plugin-content-blog';
export interface Props {
/** Blog sidebar. */
readonly sidebar: BlogSidebar;
/** All authors declared in this blog. */
readonly authors: AuthorItemProp[];
}
export default function BlogAuthorsListPage(props: Props): JSX.Element;
}
declare module '@theme/Blog/Pages/BlogAuthorsPostsPage' {
import type {Content} from '@theme/BlogPostPage';
import type {
AuthorItemProp,
BlogSidebar,
BlogPaginatedMetadata,
} from '@docusaurus/plugin-content-blog';
export interface Props {
/** Blog sidebar. */
readonly sidebar: BlogSidebar;
/** Metadata of this author. */
readonly author: AuthorItemProp;
/** Looks exactly the same as the posts list page */
readonly listMetadata: BlogPaginatedMetadata;
/**
* Array of blog posts included on this page. Every post's metadata is also
* available.
*/
readonly items: readonly {readonly content: Content}[];
}
export default function BlogAuthorsPostsPage(props: Props): JSX.Element;
}
declare module '@theme/BlogTagsPostsPage' {
import type {Content} from '@theme/BlogPostPage';
import type {

View file

@ -6,6 +6,8 @@
*/
import type {TagsListItem, TagModule} from '@docusaurus/utils';
import type {
AuthorItemProp,
AuthorWithKey,
BlogPost,
BlogSidebar,
BlogTag,
@ -40,6 +42,19 @@ export function toTagProp({
};
}
export function toAuthorItemProp({
author,
count,
}: {
author: AuthorWithKey;
count: number;
}): AuthorItemProp {
return {
...author,
count,
};
}
export function toBlogSidebarProp({
blogSidebarTitle,
blogPosts,

View file

@ -11,9 +11,15 @@ import {
docuHash,
aliasedSitePathToRelativePath,
} from '@docusaurus/utils';
import {shouldBeListed} from './blogUtils';
import {paginateBlogPosts, shouldBeListed} from './blogUtils';
import {toBlogSidebarProp, toTagProp, toTagsProp} from './props';
import {
toAuthorItemProp,
toBlogSidebarProp,
toTagProp,
toTagsProp,
} from './props';
import {groupBlogPostsByAuthorKey} from './authors';
import type {
PluginContentLoadedActions,
RouteConfig,
@ -26,6 +32,7 @@ import type {
BlogContent,
PluginOptions,
BlogPost,
AuthorWithKey,
} from '@docusaurus/plugin-content-blog';
type CreateAllRoutesParam = {
@ -54,11 +61,16 @@ export async function buildAllRoutes({
blogListComponent,
blogPostComponent,
blogTagsListComponent,
blogAuthorsListComponent,
blogAuthorsPostsComponent,
blogTagsPostsComponent,
blogArchiveComponent,
routeBasePath,
archiveBasePath,
blogTitle,
authorsBasePath,
postsPerPage,
blogDescription,
} = options;
const pluginId = options.id!;
const {createData} = actions;
@ -68,8 +80,15 @@ export async function buildAllRoutes({
blogListPaginated,
blogTags,
blogTagsListPath,
authorsMap,
} = content;
const authorsListPath = normalizeUrl([
baseUrl,
routeBasePath,
authorsBasePath,
]);
const listedBlogPosts = blogPosts.filter(shouldBeListed);
const blogPostsById = _.keyBy(blogPosts, (post) => post.id);
@ -102,6 +121,7 @@ export async function buildAllRoutes({
const blogMetadata: BlogMetadata = {
blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
blogTitle,
authorsListPath,
};
const modulePath = await createData(
`blogMetadata-${pluginId}.json`,
@ -249,10 +269,85 @@ export async function buildAllRoutes({
return [tagsListRoute, ...tagsPaginatedRoutes];
}
function createAuthorsRoutes(): RouteConfig[] {
if (authorsMap === undefined || Object.keys(authorsMap).length === 0) {
return [];
}
const blogPostsByAuthorKey = groupBlogPostsByAuthorKey({
authorsMap,
blogPosts,
});
const authors = Object.values(authorsMap);
return [
createAuthorListRoute(),
...authors.flatMap(createAuthorPaginatedRoute),
];
function createAuthorListRoute(): RouteConfig {
return {
path: authorsListPath,
component: blogAuthorsListComponent,
exact: true,
modules: {
sidebar: sidebarModulePath,
},
props: {
authors: authors.map((author) =>
toAuthorItemProp({
author,
count: blogPostsByAuthorKey[author.key]?.length ?? 0,
}),
),
},
context: {
blogMetadata: blogMetadataModulePath,
},
};
}
function createAuthorPaginatedRoute(author: AuthorWithKey): RouteConfig[] {
const authorBlogPosts = blogPostsByAuthorKey[author.key] ?? [];
if (!author.page) {
return [];
}
const pages = paginateBlogPosts({
blogPosts: authorBlogPosts,
basePageUrl: author.page.permalink,
blogDescription,
blogTitle,
pageBasePath: authorsBasePath,
postsPerPageOption: postsPerPage,
});
return pages.map(({metadata, items}) => {
return {
path: metadata.permalink,
component: blogAuthorsPostsComponent,
exact: true,
modules: {
items: blogPostItemsModule(items),
sidebar: sidebarModulePath,
},
props: {
author: toAuthorItemProp({author, count: authorBlogPosts.length}),
listMetadata: metadata,
},
context: {
blogMetadata: blogMetadataModulePath,
},
};
});
}
}
return [
...createBlogPostRoutes(),
...createBlogPostsPaginatedRoutes(),
...createTagsRoutes(),
...createArchiveRoute(),
...createAuthorsRoutes(),
];
}

View file

@ -217,6 +217,7 @@ exports[`DefaultSidebarItemsGenerator uses explicit link over the index/readme.{
{
"collapsed": undefined,
"collapsible": undefined,
"description": "Category description",
"items": [
{
"id": "parent/doc2",

View file

@ -331,6 +331,7 @@ describe('DefaultSidebarItemsGenerator', () => {
categoriesMetadata: {
Category: {
label: 'Category label',
description: 'Category description',
link: {
type: 'doc',
id: 'doc3', // Using a "local doc id" ("doc1" instead of "parent/doc1") on purpose

View file

@ -282,6 +282,7 @@ describe('validateCategoryMetadataFile', () => {
const content: CategoryMetadataFile = {
className: 'className',
label: 'Category Label',
description: 'Category Description',
link: {
type: 'generated-index',
slug: 'slug',

View file

@ -249,6 +249,9 @@ Available doc IDs:
...(customProps !== undefined && {customProps}),
...(className !== undefined && {className}),
items,
...(categoryMetadata?.description && {
description: categoryMetadata?.description,
}),
...(link && {link}),
};
}

View file

@ -64,6 +64,7 @@ function postProcessSidebarItem(
.map((subItem) => postProcessSidebarItem(subItem, params))
.filter((v): v is SidebarItem => Boolean(v)),
};
// If the current category doesn't have subitems, we render a normal link
// instead.
if (category.items.length === 0) {

View file

@ -217,6 +217,7 @@ export type PropSidebarBreadcrumbsItem =
export type CategoryMetadataFile = {
label?: string;
position?: number;
description?: string;
collapsed?: boolean;
collapsible?: boolean;
className?: string;

View file

@ -167,6 +167,7 @@ export function validateSidebars(sidebars: {
const categoryMetadataFileSchema = Joi.object<CategoryMetadataFile>({
label: Joi.string(),
description: Joi.string(),
position: Joi.number(),
collapsed: Joi.boolean(),
collapsible: Joi.boolean(),

View file

@ -127,6 +127,27 @@ export default function getSwizzleConfig(): SwizzleConfig {
description:
'The object mapping admonition type to a React component.\nUse it to add custom admonition type components, or replace existing ones.\nCan be ejected or wrapped (only manually, see our documentation).',
},
Blog: {
actions: {
// Forbidden because it's a parent folder, makes the CLI crash atm
eject: 'forbidden',
wrap: 'forbidden',
},
},
'Blog/Components': {
actions: {
// Forbidden because it's a parent folder, makes the CLI crash atm
eject: 'forbidden',
wrap: 'forbidden',
},
},
'Blog/Pages': {
actions: {
// Forbidden because it's a parent folder, makes the CLI crash atm
eject: 'forbidden',
wrap: 'forbidden',
},
},
CodeBlock: {
actions: {
eject: 'safe',

View file

@ -185,6 +185,30 @@ declare module '@theme/BackToTopButton' {
export default function BackToTopButton(): JSX.Element;
}
declare module '@theme/Blog/Components/Author' {
import type {Author} from '@docusaurus/plugin-content-blog';
export interface Props {
readonly as?: 'h1' | 'h2';
readonly author: Author;
readonly className?: string;
readonly count?: number;
}
export default function BlogAuthor(props: Props): JSX.Element;
}
declare module '@theme/Blog/Components/Author/Socials' {
import type {Author} from '@docusaurus/plugin-content-blog';
export interface Props {
readonly author: Author;
readonly className?: string;
}
export default function BlogAuthorSocials(props: Props): JSX.Element;
}
declare module '@theme/BlogListPaginator' {
import type {BlogPaginatedMetadata} from '@docusaurus/plugin-content-blog';
@ -291,31 +315,6 @@ declare module '@theme/BlogPostItem/Header/Info' {
export default function BlogPostItemHeaderInfo(): JSX.Element;
}
declare module '@theme/BlogPostItem/Header/Author' {
import type {Author} from '@docusaurus/plugin-content-blog';
export interface Props {
readonly author: Author;
readonly singleAuthor: boolean;
readonly className?: string;
}
export default function BlogPostItemHeaderAuthor(props: Props): JSX.Element;
}
declare module '@theme/BlogPostItem/Header/Author/Socials' {
import type {Author} from '@docusaurus/plugin-content-blog';
export interface Props {
readonly author: Author;
readonly className?: string;
}
export default function BlogPostItemHeaderAuthorSocials(
props: Props,
): JSX.Element;
}
declare module '@theme/BlogPostItem/Header/Authors' {
export interface Props {
readonly className?: string;
@ -1603,7 +1602,19 @@ declare module '@theme/Tag' {
export default function Tag(props: Props): JSX.Element;
}
declare module '@theme/Unlisted' {
declare module '@theme/ContentVisibility' {
export interface Props {
readonly metadata: {
// the visibility metadata our 3 content plugins share in common
readonly unlisted: boolean;
readonly frontMatter: {draft?: boolean; unlisted?: boolean};
};
}
export default function ContentVisibility(props: Props): JSX.Element;
}
declare module '@theme/ContentVisibility/Unlisted' {
export interface Props {
className?: string;
}
@ -1611,6 +1622,14 @@ declare module '@theme/Unlisted' {
export default function Unlisted(props: Props): JSX.Element;
}
declare module '@theme/ContentVisibility/Draft' {
export interface Props {
className?: string;
}
export default function Draft(props: Props): JSX.Element;
}
declare module '@theme/prism-include-languages' {
import type * as PrismNamespace from 'prismjs';

View file

@ -9,7 +9,7 @@ import type {ComponentType} from 'react';
import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import type {Props} from '@theme/BlogPostItem/Header/Author/Socials';
import type {Props} from '@theme/Blog/Components/Author/Socials';
import Twitter from '@theme/Icon/Socials/Twitter';
import GitHub from '@theme/Icon/Socials/GitHub';
@ -50,10 +50,15 @@ function SocialLink({platform, link}: {platform: string; link: string}) {
);
}
export default function AuthorSocials({author}: {author: Props['author']}) {
export default function BlogAuthorSocials({
author,
}: {
author: Props['author'];
}): JSX.Element {
const entries = Object.entries(author.socials ?? {});
return (
<div className={styles.authorSocials}>
{Object.entries(author.socials ?? {}).map(([platform, linkUrl]) => {
{entries.map(([platform, linkUrl]) => {
return <SocialLink key={platform} platform={platform} link={linkUrl} />;
})}
</div>

View file

@ -10,7 +10,12 @@
}
.authorSocials {
margin-top: 0.2rem;
/*
This ensures that container takes height even if there's no social link
This keeps author names aligned even if only some have socials
*/
height: var(--docusaurus-blog-social-icon-size);
display: flex;
flex-wrap: wrap;
align-items: center;
@ -25,7 +30,7 @@
height: var(--docusaurus-blog-social-icon-size);
width: var(--docusaurus-blog-social-icon-size);
line-height: 0;
margin-right: 0.3rem;
margin-right: 0.4rem;
}
.authorSocialIcon {

View file

@ -0,0 +1,99 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import Link, {type Props as LinkProps} from '@docusaurus/Link';
import AuthorSocials from '@theme/Blog/Components/Author/Socials';
import type {Props} from '@theme/Blog/Components/Author';
import Heading from '@theme/Heading';
import styles from './styles.module.css';
function MaybeLink(props: LinkProps): JSX.Element {
if (props.href) {
return <Link {...props} />;
}
return <>{props.children}</>;
}
function AuthorTitle({title}: {title: string}) {
return (
<small className={styles.authorTitle} title={title}>
{title}
</small>
);
}
function AuthorName({name, as}: {name: string; as: Props['as']}) {
if (!as) {
return <span className={styles.authorName}>{name}</span>;
} else {
return (
<Heading as={as} className={styles.authorName}>
{name}
</Heading>
);
}
}
function AuthorBlogPostCount({count}: {count: number}) {
return <span className={clsx(styles.authorBlogPostCount)}>{count}</span>;
}
// Note: in the future we might want to have multiple "BlogAuthor" components
// Creating different display modes with the "as" prop may not be the best idea
// Explainer: https://kyleshevlin.com/prefer-multiple-compositions/
// For now, we almost use the same design for all cases, so it's good enough
export default function BlogAuthor({
as,
author,
className,
count,
}: Props): JSX.Element {
const {name, title, url, imageURL, email, page} = author;
const link =
page?.permalink || url || (email && `mailto:${email}`) || undefined;
return (
<div
className={clsx(
'avatar margin-bottom--sm',
className,
styles[`author-as-${as}`],
)}>
{imageURL && (
<MaybeLink href={link} className="avatar__photo-link">
<img
className={clsx('avatar__photo', styles.authorImage)}
src={imageURL}
alt={name}
/>
</MaybeLink>
)}
{(name || title) && (
<div className={clsx('avatar__intro', styles.authorDetails)}>
<div className="avatar__name">
{name && (
<MaybeLink href={link}>
<AuthorName name={name} as={as} />
</MaybeLink>
)}
{count && <AuthorBlogPostCount count={count} />}
</div>
{!!title && <AuthorTitle title={title} />}
{/*
We always render AuthorSocials even if there's none
This keeps other things aligned with flexbox layout
*/}
<AuthorSocials author={author} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,74 @@
/**
* 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.
*/
.authorImage {
--ifm-avatar-photo-size: 3.6rem;
}
.author-as-h1 .authorImage {
--ifm-avatar-photo-size: 7rem;
}
.author-as-h2 .authorImage {
--ifm-avatar-photo-size: 5.4rem;
}
.authorDetails {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-around;
}
.authorName {
font-size: 1.1rem;
line-height: 1.1rem;
display: flex;
flex-direction: row;
}
.author-as-h1 .authorName {
font-size: 2.4rem;
line-height: 2.4rem;
display: inline;
}
.author-as-h2 .authorName {
font-size: 1.4rem;
line-height: 1.4rem;
display: inline;
}
.authorTitle {
font-size: 0.8rem;
line-height: 0.8rem;
display: -webkit-box;
overflow: hidden;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.author-as-h1 .authorTitle {
font-size: 1.2rem;
line-height: 1.2rem;
}
.author-as-h2 .authorTitle {
font-size: 1rem;
line-height: 1rem;
}
.authorBlogPostCount {
background: var(--ifm-color-secondary);
color: var(--ifm-color-black);
font-size: 0.8rem;
line-height: 1.2;
border-radius: var(--ifm-global-radius);
padding: 0.1rem 0.4rem;
margin-left: 0.3rem;
}

View file

@ -0,0 +1,62 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import clsx from 'clsx';
import {
PageMetadata,
HtmlClassNameProvider,
ThemeClassNames,
} from '@docusaurus/theme-common';
import {translateBlogAuthorsListPageTitle} from '@docusaurus/theme-common/internal';
import BlogLayout from '@theme/BlogLayout';
import type {Props} from '@theme/Blog/Pages/BlogAuthorsListPage';
import SearchMetadata from '@theme/SearchMetadata';
import Heading from '@theme/Heading';
import Author from '@theme/Blog/Components/Author';
import type {AuthorItemProp} from '@docusaurus/plugin-content-blog';
import styles from './styles.module.css';
function AuthorListItem({author}: {author: AuthorItemProp}) {
return (
<li className={styles.authorListItem}>
<Author as="h2" author={author} count={author.count} />
</li>
);
}
function AuthorsList({authors}: {authors: Props['authors']}) {
return (
<section className={clsx('margin-vert--lg', styles.authorsListSection)}>
<ul>
{authors.map((author) => (
<AuthorListItem key={author.key} author={author} />
))}
</ul>
</section>
);
}
export default function BlogAuthorsListPage({
authors,
sidebar,
}: Props): ReactNode {
const title: string = translateBlogAuthorsListPageTitle();
return (
<HtmlClassNameProvider
className={clsx(
ThemeClassNames.wrapper.blogPages,
ThemeClassNames.page.blogAuthorsListPage,
)}>
<PageMetadata title={title} />
<SearchMetadata tag="blog_authors_list" />
<BlogLayout sidebar={sidebar}>
<Heading as="h1">{title}</Heading>
<AuthorsList authors={authors} />
</BlogLayout>
</HtmlClassNameProvider>
);
}

View file

@ -0,0 +1,11 @@
/**
* 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.
*/
.authorListItem {
list-style-type: none;
margin-bottom: 2rem;
}

View file

@ -0,0 +1,73 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import {
PageMetadata,
HtmlClassNameProvider,
ThemeClassNames,
} from '@docusaurus/theme-common';
import {
useBlogAuthorPageTitle,
BlogAuthorsListViewAllLabel,
} from '@docusaurus/theme-common/internal';
import Link from '@docusaurus/Link';
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
import BlogLayout from '@theme/BlogLayout';
import BlogListPaginator from '@theme/BlogListPaginator';
import SearchMetadata from '@theme/SearchMetadata';
import type {Props} from '@theme/Blog/Pages/BlogAuthorsPostsPage';
import BlogPostItems from '@theme/BlogPostItems';
import Author from '@theme/Blog/Components/Author';
function Metadata({author}: Props): JSX.Element {
const title = useBlogAuthorPageTitle(author);
return (
<>
<PageMetadata title={title} />
<SearchMetadata tag="blog_authors_posts" />
</>
);
}
function ViewAllAuthorsLink() {
const {authorsListPath} = useBlogMetadata();
return (
<Link href={authorsListPath}>
<BlogAuthorsListViewAllLabel />
</Link>
);
}
function Content({author, items, sidebar, listMetadata}: Props): JSX.Element {
return (
<BlogLayout sidebar={sidebar}>
<header className="margin-bottom--xl">
<Author as="h1" author={author} />
{author.description && <p>{author.description}</p>}
<ViewAllAuthorsLink />
</header>
<hr />
<BlogPostItems items={items} />
<BlogListPaginator metadata={listMetadata} />
</BlogLayout>
);
}
export default function BlogAuthorsPostsPage(props: Props): JSX.Element {
return (
<HtmlClassNameProvider
className={clsx(
ThemeClassNames.wrapper.blogPages,
ThemeClassNames.page.blogAuthorsPostsPage,
)}>
<Metadata {...props} />
<Content {...props} />
</HtmlClassNameProvider>
);
}

View file

@ -1,62 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import Link, {type Props as LinkProps} from '@docusaurus/Link';
import AuthorSocials from '@theme/BlogPostItem/Header/Author/Socials';
import type {Props} from '@theme/BlogPostItem/Header/Author';
import styles from './styles.module.css';
function MaybeLink(props: LinkProps): JSX.Element {
if (props.href) {
return <Link {...props} />;
}
return <>{props.children}</>;
}
function AuthorTitle({title}: {title: string}) {
return (
<small className={styles.authorTitle} title={title}>
{title}
</small>
);
}
export default function BlogPostItemHeaderAuthor({
// singleAuthor, // may be useful in the future, or for swizzle users
author,
className,
}: Props): JSX.Element {
const {name, title, url, socials, imageURL, email} = author;
const link = url || (email && `mailto:${email}`) || undefined;
const hasSocials = socials && Object.keys(socials).length > 0;
return (
<div className={clsx('avatar margin-bottom--sm', className)}>
{imageURL && (
<MaybeLink href={link} className="avatar__photo-link">
<img className="avatar__photo" src={imageURL} alt={name} />
</MaybeLink>
)}
{(name || title) && (
<div className="avatar__intro">
<div className="avatar__name">
<MaybeLink href={link}>
<span className={styles.authorName}>{name}</span>
</MaybeLink>
</div>
{!!title && <AuthorTitle title={title} />}
{hasSocials && <AuthorSocials author={author} />}
</div>
)}
</div>
);
}

View file

@ -1,21 +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.
*/
.authorName {
font-size: 1.1rem;
}
.authorTitle {
margin-top: 0.06rem;
font-size: 0.8rem;
line-height: 0.8rem;
display: -webkit-box;
overflow: hidden;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import clsx from 'clsx';
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
import BlogPostItemHeaderAuthor from '@theme/BlogPostItem/Header/Author';
import BlogAuthor from '@theme/Blog/Components/Author';
import type {Props} from '@theme/BlogPostItem/Header/Authors';
import styles from './styles.module.css';
@ -40,8 +40,7 @@ export default function BlogPostItemHeaderAuthors({
imageOnly ? styles.imageOnlyAuthorCol : styles.authorCol,
)}
key={idx}>
<BlogPostItemHeaderAuthor
singleAuthor={singleAuthor}
<BlogAuthor
author={{
...author,
// Handle author images using relative paths

View file

@ -18,8 +18,8 @@ import BlogPostPaginator from '@theme/BlogPostPaginator';
import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
import BlogPostPageStructuredData from '@theme/BlogPostPage/StructuredData';
import TOC from '@theme/TOC';
import ContentVisibility from '@theme/ContentVisibility';
import type {Props} from '@theme/BlogPostPage';
import Unlisted from '@theme/Unlisted';
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
function BlogPostPageContent({
@ -30,7 +30,7 @@ function BlogPostPageContent({
children: ReactNode;
}): JSX.Element {
const {metadata, toc} = useBlogPost();
const {nextItem, prevItem, frontMatter, unlisted} = metadata;
const {nextItem, prevItem, frontMatter} = metadata;
const {
hide_table_of_contents: hideTableOfContents,
toc_min_heading_level: tocMinHeadingLevel,
@ -48,7 +48,7 @@ function BlogPostPageContent({
/>
) : undefined
}>
{unlisted && <Unlisted />}
<ContentVisibility metadata={metadata} />
<BlogPostItem>{children}</BlogPostItem>

View file

@ -7,52 +7,22 @@
import React from 'react';
import clsx from 'clsx';
import Translate, {translate} from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import {
PageMetadata,
HtmlClassNameProvider,
ThemeClassNames,
usePluralForm,
} from '@docusaurus/theme-common';
import {useBlogTagsPostsPageTitle} from '@docusaurus/theme-common/internal';
import Link from '@docusaurus/Link';
import BlogLayout from '@theme/BlogLayout';
import BlogListPaginator from '@theme/BlogListPaginator';
import SearchMetadata from '@theme/SearchMetadata';
import type {Props} from '@theme/BlogTagsPostsPage';
import BlogPostItems from '@theme/BlogPostItems';
import Unlisted from '@theme/Unlisted';
import Unlisted from '@theme/ContentVisibility/Unlisted';
import Heading from '@theme/Heading';
// Very simple pluralization: probably good enough for now
function useBlogPostsPlural() {
const {selectMessage} = usePluralForm();
return (count: number) =>
selectMessage(
count,
translate(
{
id: 'theme.blog.post.plurals',
description:
'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: 'One post|{count} posts',
},
{count},
),
);
}
function useBlogTagsPostsPageTitle(tag: Props['tag']): string {
const blogPostsPlural = useBlogPostsPlural();
return translate(
{
id: 'theme.blog.tagTitle',
description: 'The title of the page for a blog tag',
message: '{nPosts} tagged with "{tagName}"',
},
{nPosts: blogPostsPlural(tag.count), tagName: tag.label},
);
}
function BlogTagsPostsPageMetadata({tag}: Props): JSX.Element {
const title = useBlogTagsPostsPageTitle(tag);
return (

View file

@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import {
ThemeClassNames,
DraftBannerTitle,
DraftBannerMessage,
} from '@docusaurus/theme-common';
import Admonition from '@theme/Admonition';
import type {Props} from '@theme/ContentVisibility/Draft';
export default function Draft({className}: Props): JSX.Element | null {
return (
<Admonition
type="caution"
title={<DraftBannerTitle />}
className={clsx(className, ThemeClassNames.common.draftBanner)}>
<DraftBannerMessage />
</Admonition>
);
}

View file

@ -14,7 +14,7 @@ import {
UnlistedMetadata,
} from '@docusaurus/theme-common';
import Admonition from '@theme/Admonition';
import type {Props} from '@theme/Unlisted';
import type {Props} from '@theme/ContentVisibility/Unlisted';
function UnlistedBanner({className}: Props) {
return (

View file

@ -0,0 +1,27 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import type {Props} from '@theme/ContentVisibility';
import Draft from '@theme/ContentVisibility/Draft';
import Unlisted from '@theme/ContentVisibility/Unlisted';
export default function ContentVisibility({
metadata,
}: Props): JSX.Element | null {
const {unlisted, frontMatter} = metadata;
// Reading draft/unlisted status from frontMatter is useful to display
// the banners in dev mode (in dev, metadata.unlisted is always false)
// See https://github.com/facebook/docusaurus/issues/8285
return (
<>
{(unlisted || frontMatter.unlisted) && <Unlisted />}
{frontMatter.draft && <Draft />}
</>
);
}

View file

@ -17,7 +17,7 @@ import DocItemTOCMobile from '@theme/DocItem/TOC/Mobile';
import DocItemTOCDesktop from '@theme/DocItem/TOC/Desktop';
import DocItemContent from '@theme/DocItem/Content';
import DocBreadcrumbs from '@theme/DocBreadcrumbs';
import Unlisted from '@theme/Unlisted';
import ContentVisibility from '@theme/ContentVisibility';
import type {Props} from '@theme/DocItem/Layout';
import styles from './styles.module.css';
@ -48,13 +48,11 @@ function useDocTOC() {
export default function DocItemLayout({children}: Props): JSX.Element {
const docTOC = useDocTOC();
const {
metadata: {unlisted},
} = useDoc();
const {metadata} = useDoc();
return (
<div className="row">
<div className={clsx('col', !docTOC.hidden && styles.docItemCol)}>
{unlisted && <Unlisted />}
<ContentVisibility metadata={metadata} />
<DocVersionBanner />
<div className={styles.docItemContainer}>
<article>

View file

@ -17,7 +17,7 @@ import {
import Translate, {translate} from '@docusaurus/Translate';
import SearchMetadata from '@theme/SearchMetadata';
import type {Props} from '@theme/DocTagDocListPage';
import Unlisted from '@theme/Unlisted';
import Unlisted from '@theme/ContentVisibility/Unlisted';
import Heading from '@theme/Heading';
// Very simple pluralization: probably good enough for now

View file

@ -15,7 +15,7 @@ import {
import Layout from '@theme/Layout';
import MDXContent from '@theme/MDXContent';
import TOC from '@theme/TOC';
import Unlisted from '@theme/Unlisted';
import ContentVisibility from '@theme/ContentVisibility';
import type {Props} from '@theme/MDXPage';
import EditMetaRow from '@theme/EditMetaRow';
@ -23,18 +23,15 @@ import styles from './styles.module.css';
export default function MDXPage(props: Props): JSX.Element {
const {content: MDXPageContent} = props;
const {metadata, assets} = MDXPageContent;
const {
metadata: {
title,
editUrl,
description,
frontMatter,
unlisted,
lastUpdatedBy,
lastUpdatedAt,
},
assets,
} = MDXPageContent;
} = metadata;
const {
keywords,
wrapperClassName,
@ -60,7 +57,7 @@ export default function MDXPage(props: Props): JSX.Element {
<main className="container container--fluid margin-vert--lg">
<div className={clsx('row', styles.mdxPageWrapper)}>
<div className={clsx('col', !hideTableOfContents && 'col--8')}>
{unlisted && <Unlisted />}
<ContentVisibility metadata={metadata} />
<article>
<MDXContent>
<MDXPageContent />

View file

@ -123,7 +123,9 @@ export {
UnlistedBannerTitle,
UnlistedBannerMessage,
UnlistedMetadata,
} from './utils/unlistedUtils';
DraftBannerTitle,
DraftBannerMessage,
} from './translations/contentVisibilityTranslations';
export {
ErrorBoundaryTryAgainButton,

View file

@ -90,3 +90,10 @@ export {useLockBodyScroll} from './hooks/useLockBodyScroll';
export {useCodeWordWrap} from './hooks/useCodeWordWrap';
export {getPrismCssVariables} from './utils/codeBlockUtils';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {
useBlogTagsPostsPageTitle,
useBlogAuthorPageTitle,
translateBlogAuthorsListPageTitle,
BlogAuthorsListViewAllLabel,
} from './translations/blogTranslations';

View file

@ -0,0 +1,79 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import Translate, {translate} from '@docusaurus/Translate';
import {usePluralForm} from '../utils/usePluralForm';
// Only used locally
function useBlogPostsPlural(): (count: number) => string {
const {selectMessage} = usePluralForm();
return (count: number) =>
selectMessage(
count,
translate(
{
id: 'theme.blog.post.plurals',
description:
'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
message: 'One post|{count} posts',
},
{count},
),
);
}
export function useBlogTagsPostsPageTitle(tag: {
label: string;
count: number;
}): string {
const blogPostsPlural = useBlogPostsPlural();
return translate(
{
id: 'theme.blog.tagTitle',
description: 'The title of the page for a blog tag',
message: '{nPosts} tagged with "{tagName}"',
},
{nPosts: blogPostsPlural(tag.count), tagName: tag.label},
);
}
export function useBlogAuthorPageTitle(author: {
key: string;
name?: string;
count: number;
}): string {
const blogPostsPlural = useBlogPostsPlural();
return translate(
{
id: 'theme.blog.author.pageTitle',
description: 'The title of the page for a blog author',
message: '{authorName} - {nPosts}',
},
{
nPosts: blogPostsPlural(author.count),
authorName: author.name || author.key,
},
);
}
export const translateBlogAuthorsListPageTitle = (): string =>
translate({
id: 'theme.blog.authorsList.pageTitle',
message: 'Authors',
description: 'The title of the authors page',
});
export function BlogAuthorsListViewAllLabel(): ReactNode {
return (
<Translate
id="theme.blog.authorsList.viewAll"
description="The label of the link targeting the blog authors page">
View All Authors
</Translate>
);
}

View file

@ -12,7 +12,7 @@ import Head from '@docusaurus/Head';
export function UnlistedBannerTitle(): JSX.Element {
return (
<Translate
id="theme.unlistedContent.title"
id="theme.contentVisibility.unlistedBanner.title"
description="The unlisted content banner title">
Unlisted page
</Translate>
@ -22,7 +22,7 @@ export function UnlistedBannerTitle(): JSX.Element {
export function UnlistedBannerMessage(): JSX.Element {
return (
<Translate
id="theme.unlistedContent.message"
id="theme.contentVisibility.unlistedBanner.message"
description="The unlisted content banner message">
This page is unlisted. Search engines will not index it, and only users
having a direct link can access it.
@ -30,6 +30,8 @@ export function UnlistedBannerMessage(): JSX.Element {
);
}
// TODO Docusaurus v4 breaking change (since it's v3 public theme-common API :/)
// Move this to theme/ContentVisibility/Unlisted
export function UnlistedMetadata(): JSX.Element {
return (
<Head>
@ -37,3 +39,24 @@ export function UnlistedMetadata(): JSX.Element {
</Head>
);
}
export function DraftBannerTitle(): JSX.Element {
return (
<Translate
id="theme.contentVisibility.draftBanner.title"
description="The draft content banner title">
Draft page
</Translate>
);
}
export function DraftBannerMessage(): JSX.Element {
return (
<Translate
id="theme.contentVisibility.draftBanner.message"
description="The draft content banner message">
This page is a draft. It will only be visible in dev and be excluded from
the production build.
</Translate>
);
}

View file

@ -18,6 +18,8 @@ export const ThemeClassNames = {
blogPostPage: 'blog-post-page',
blogTagsListPage: 'blog-tags-list-page',
blogTagPostListPage: 'blog-tags-post-list-page',
blogAuthorsListPage: 'blog-authors-list-page',
blogAuthorsPostsPage: 'blog-authors-posts-page',
docsDocPage: 'docs-doc-page',
docsTagsListPage: 'docs-tags-list-page',
@ -41,6 +43,7 @@ export const ThemeClassNames = {
codeBlock: 'theme-code-block',
admonition: 'theme-admonition',
unlistedBanner: 'theme-unlisted-banner',
draftBanner: 'theme-draft-banner',
admonitionType: (type: string) => `theme-admonition-${type}`,
},

View file

@ -7,42 +7,47 @@
import _ from 'lodash';
import {listTagsByLetters} from '../tagsUtils';
import type {TagsListItem} from '@docusaurus/utils';
describe('listTagsByLetters', () => {
type Param = Parameters<typeof listTagsByLetters>[0];
type Tag = Param[number];
type Result = ReturnType<typeof listTagsByLetters>;
it('creates letters list', () => {
const tag1: Tag = {
const tag1: TagsListItem = {
label: 'tag1',
permalink: '/tag1',
count: 1,
description: '',
};
const tag2: Tag = {
const tag2: TagsListItem = {
label: 'Tag2',
permalink: '/tag2',
count: 11,
description: '',
};
const tagZxy: Tag = {
const tagZxy: TagsListItem = {
label: 'zxy',
permalink: '/zxy',
count: 987,
description: '',
};
const tagAbc: Tag = {
const tagAbc: TagsListItem = {
label: 'Abc',
permalink: '/abc',
count: 123,
description: '',
};
const tagDef: Tag = {
const tagDef: TagsListItem = {
label: 'def',
permalink: '/def',
count: 1,
description: '',
};
const tagAaa: Tag = {
const tagAaa: TagsListItem = {
label: 'aaa',
permalink: '/aaa',
count: 10,
description: '',
};
const expectedResult: Result = [

View file

@ -156,6 +156,10 @@ function DocSearch({
const handleInput = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'f' && (event.metaKey || event.ctrlKey)) {
// ignore browser's ctrl+f
return;
}
// prevents duplicate key insertion in the modal input
event.preventDefault();
setInitialQuery(event.key);

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "أرشيف",
"theme.blog.archive.title": "أرشيف",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "التنقل في صفحة قائمة المدونة",
"theme.blog.paginator.newerEntries": "إدخالات أحدث",
"theme.blog.paginator.olderEntries": "إدخالات أقدم",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "تعديل هذه الصفحة",
"theme.common.headingLinkTitle": "ارتباط مباشر بالعنوان {heading}",
"theme.common.skipToMainContent": "انتقل إلى المحتوى الرئيسي",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "{count} مواد",
"theme.docs.breadcrumbs.home": "الرئيسية",
"theme.docs.breadcrumbs.navAriaLabel": "التنقل التفصيلي",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "إصدارات",
"theme.tags.tagsListLabel": "الوسوم:",
"theme.tags.tagsPageLink": "عرض كل الوسوم",
"theme.tags.tagsPageTitle": "الوسوم",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "الوسوم"
}

View file

@ -45,6 +45,12 @@
"theme.blog.archive.description___DESCRIPTION": "The page & hero description of the blog archive page",
"theme.blog.archive.title": "Archive",
"theme.blog.archive.title___DESCRIPTION": "The page & hero title of the blog archive page",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.author.pageTitle___DESCRIPTION": "The title of the page for a blog author",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.pageTitle___DESCRIPTION": "The title of the authors page",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.authorsList.viewAll___DESCRIPTION": "The label of the link targeting the blog authors page",
"theme.blog.paginator.navAriaLabel": "Blog list page navigation",
"theme.blog.paginator.navAriaLabel___DESCRIPTION": "The ARIA label for the blog pagination",
"theme.blog.paginator.newerEntries": "Newer Entries",
@ -81,6 +87,14 @@
"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.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.message___DESCRIPTION": "The draft content banner message",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.draftBanner.title___DESCRIPTION": "The draft content banner title",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.message___DESCRIPTION": "The unlisted content banner message",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.contentVisibility.unlistedBanner.title___DESCRIPTION": "The unlisted content banner title",
"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",
@ -137,9 +151,5 @@
"theme.tags.tagsPageLink": "View All Tags",
"theme.tags.tagsPageLink___DESCRIPTION": "The label of the link targeting the tag list page",
"theme.tags.tagsPageTitle": "Tags",
"theme.tags.tagsPageTitle___DESCRIPTION": "The title of the tag list page",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.message___DESCRIPTION": "The unlisted content banner message",
"theme.unlistedContent.title": "Unlisted page",
"theme.unlistedContent.title___DESCRIPTION": "The unlisted content banner title"
"theme.tags.tagsPageTitle___DESCRIPTION": "The title of the tag list page"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "Внимание",
"theme.blog.archive.description": "Архив",
"theme.blog.archive.title": "Архив",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Навигация в страницата със списък на блогове",
"theme.blog.paginator.newerEntries": "По-нови записи",
"theme.blog.paginator.olderEntries": "По-стари записи",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Редактирай тази страница",
"theme.common.headingLinkTitle": "Директна връзка към {heading}",
"theme.common.skipToMainContent": "Преминете към основното съдържание",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Тази страница е скрита. Търсачките няма да я индексират и само потребители с директна връзка имат достъп до него.",
"theme.contentVisibility.unlistedBanner.title": "Скрита страница",
"theme.docs.DocCard.categoryDescription.plurals": "един предмет|{count} предмета",
"theme.docs.breadcrumbs.home": "Начална страница",
"theme.docs.breadcrumbs.navAriaLabel": "Галета",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Версии",
"theme.tags.tagsListLabel": "Етикети:",
"theme.tags.tagsPageLink": "Вижте всички етикети",
"theme.tags.tagsPageTitle": "Етикети",
"theme.unlistedContent.message": "Тази страница е скрита. Търсачките няма да я индексират и само потребители с директна връзка имат достъп до него.",
"theme.unlistedContent.title": "Скрита страница"
"theme.tags.tagsPageTitle": "Етикети"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "ব্লগ তালিকা পেজ নেভিগেশন",
"theme.blog.paginator.newerEntries": "নতুন এন্ট্রি",
"theme.blog.paginator.olderEntries": "পুরানো এন্ট্রি",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "এই পেজটি এডিট করুন",
"theme.common.headingLinkTitle": "{heading} এর সঙ্গে সরাসরি লিংকড",
"theme.common.skipToMainContent": "স্কিপ করে মূল কন্টেন্ট এ যান",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "ট্যাগ্স:",
"theme.tags.tagsPageLink": "সমস্ত ট্যাগ্স দেখুন",
"theme.tags.tagsPageTitle": "ট্যাগ্স",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "ট্যাগ্স"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Stránkování článků na blogu",
"theme.blog.paginator.newerEntries": "Novější záznamy",
"theme.blog.paginator.olderEntries": "Starší záznamy",
@ -40,6 +43,10 @@
"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.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tagy:",
"theme.tags.tagsPageLink": "Zobrazit všechny tagy",
"theme.tags.tagsPageTitle": "Tagy",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "Tagy"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Blogoversigt navigation",
"theme.blog.paginator.newerEntries": "Nyere indslag",
"theme.blog.paginator.olderEntries": "Tidligere indslag",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Rediger denne side",
"theme.common.headingLinkTitle": "Direkte link til {heading}",
"theme.common.skipToMainContent": "Hop til hovedindhold",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Se alle Tags",
"theme.tags.tagsPageTitle": "Tags",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "Tags"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warnung",
"theme.blog.archive.description": "Archiv",
"theme.blog.archive.title": "Archiv",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Navigation der Blog-Listenseite",
"theme.blog.paginator.newerEntries": "Neuere Einträge",
"theme.blog.paginator.olderEntries": "Ältere Einträge",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Diese Seite bearbeiten",
"theme.common.headingLinkTitle": "Direkter Link zur {heading}",
"theme.common.skipToMainContent": "Zum Hauptinhalt springen",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 Eintrag|{count} Einträge",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versionen",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Alle Tags anzeigen",
"theme.tags.tagsPageTitle": "Tags",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "Tags"
}

View file

@ -3,5 +3,5 @@
"theme.IdealImageMessage.error": "Error. Click para recargar",
"theme.IdealImageMessage.load": "Click para recargar{sizeMessage}",
"theme.IdealImageMessage.loading": "Cargando...",
"theme.IdealImageMessage.offline": "Tu navegador está desconectado. Image no cargada"
"theme.IdealImageMessage.offline": "Tu navegador está desconectado. Imagen no cargada"
}

View file

@ -5,23 +5,26 @@
"theme.CodeBlock.copy": "Copiar",
"theme.CodeBlock.copyButtonAriaLabel": "Copiar código",
"theme.CodeBlock.wordWrapToggle": "Alternar ajuste de palabras",
"theme.DocSidebarItem.collapseCategoryAriaLabel": "Colapsar categoría '{label}' de barra lateral",
"theme.DocSidebarItem.collapseCategoryAriaLabel": "Colapsar categoría '{label}' de la barra lateral",
"theme.DocSidebarItem.expandCategoryAriaLabel": "Ampliar la categoría '{label}' de la barra lateral",
"theme.ErrorPageContent.title": "Esta página ha fallado.",
"theme.ErrorPageContent.tryAgain": "Intente de nuevo",
"theme.NavBar.navAriaLabel": "Principal",
"theme.NotFound.p1": "No pudimos encontrar lo que buscaba.",
"theme.NotFound.p2": "Comuníquese con el dueño del sitio que lo vinculó a la URL original y hágale saber que su vínculo está roto.",
"theme.NotFound.p2": "Comuníquese con el dueño del sitio que le proporcionó la URL original y hágale saber que su vínculo está roto.",
"theme.NotFound.title": "Página No Encontrada",
"theme.TOCCollapsible.toggleButtonLabel": "En esta página",
"theme.admonition.caution": "precaución",
"theme.admonition.danger": "danger",
"theme.admonition.danger": "peligro",
"theme.admonition.info": "info",
"theme.admonition.note": "note",
"theme.admonition.note": "nota",
"theme.admonition.tip": "tip",
"theme.admonition.warning": "warning",
"theme.admonition.warning": "aviso",
"theme.blog.archive.description": "Archivo",
"theme.blog.archive.title": "Archivo",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Navegación por la página de la lista de blogs ",
"theme.blog.paginator.newerEntries": "Entradas más recientes",
"theme.blog.paginator.olderEntries": "Entradas más antiguas",
@ -30,7 +33,7 @@
"theme.blog.post.paginator.olderPost": "Publicación más antigua",
"theme.blog.post.plurals": "Una publicación|{count} publicaciones",
"theme.blog.post.readMore": "Leer Más",
"theme.blog.post.readMoreLabel": "Leer más acerca {title}",
"theme.blog.post.readMoreLabel": "Leer más acerca de {title}",
"theme.blog.post.readingTime.plurals": "Lectura de un minuto|{readingTime} min de lectura",
"theme.blog.sidebar.navAriaLabel": "Navegación de publicaciones recientes",
"theme.blog.tagTitle": "{nPosts} etiquetados con \"{tagName}\"",
@ -40,9 +43,13 @@
"theme.common.editThisPage": "Editar esta página",
"theme.common.headingLinkTitle": "Enlace directo al {heading}",
"theme.common.skipToMainContent": "Saltar al contenido principal",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Esta página está sin clasificar. Los motores de búsqueda no la indexaran, y solo los usuarios con el enlace directo podrán acceder a esta.",
"theme.contentVisibility.unlistedBanner.title": "Página sin clasificar",
"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.breadcrumbs.navAriaLabel": "Migas de pan",
"theme.docs.paginator.navAriaLabel": "Página del documento",
"theme.docs.paginator.next": "Siguiente",
"theme.docs.paginator.previous": "Anterior",
@ -58,17 +65,15 @@
"theme.docs.versionBadge.label": "Version: {versionLabel}",
"theme.docs.versions.latestVersionLinkLabel": "última versión",
"theme.docs.versions.latestVersionSuggestionLabel": "Para la documentación actualizada, vea {latestVersionLink} ({versionLabel}).",
"theme.docs.versions.unmaintainedVersionLabel": "Esta es documentación para {siteTitle} {versionLabel}, que ya no se mantiene activamente.",
"theme.docs.versions.unreleasedVersionLabel": "Esta es documentación sin liberar para {siteTitle} {versionLabel} versión.",
"theme.docs.versions.unmaintainedVersionLabel": "Esta es la documentación para {siteTitle} {versionLabel}, que ya no se mantiene activamente.",
"theme.docs.versions.unreleasedVersionLabel": "Esta es la documentación sin publicar para {siteTitle}, versión {versionLabel}.",
"theme.lastUpdated.atDate": " en {date}",
"theme.lastUpdated.byUser": " por {user}",
"theme.lastUpdated.lastUpdatedAtBy": "Última actualización{atDate}{byUser}",
"theme.navbar.mobileLanguageDropdown.label": "Lenguajes",
"theme.navbar.mobileLanguageDropdown.label": "Idiomas",
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": "← Volver al menú principal",
"theme.navbar.mobileVersionsDropdown.label": "Versiones",
"theme.tags.tagsListLabel": "Etiquetas:",
"theme.tags.tagsPageLink": "Ver Todas las Etiquetas",
"theme.tags.tagsPageTitle": "Etiquetas",
"theme.unlistedContent.message": "Esta página está sin clasificar. Los motores de búsqueda no la indexaran, y solo los usuarios con el enlace directo podrán acceder a esta.",
"theme.unlistedContent.title": "Página sin clasificar"
"theme.tags.tagsPageTitle": "Etiquetas"
}

View file

@ -13,7 +13,7 @@
"theme.SearchModal.footer.selectText": "seleccionar",
"theme.SearchModal.noResultsScreen.noResultsText": "Sin resultados para",
"theme.SearchModal.noResultsScreen.reportMissingResultsLinkText": "Háganos saber.",
"theme.SearchModal.noResultsScreen.reportMissingResultsText": "Creo que esta consulta debería devolver resultados?",
"theme.SearchModal.noResultsScreen.reportMissingResultsText": "Crees que esta consulta debería devolver resultados?",
"theme.SearchModal.noResultsScreen.suggestedQueryText": "Intenta buscando por",
"theme.SearchModal.placeholder": "Buscar documentos",
"theme.SearchModal.searchBox.cancelButtonText": "Cancelar",

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "hoiatus",
"theme.blog.archive.description": "Arhiiv",
"theme.blog.archive.title": "Arhiiv",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Blogi lehekülje navigatsioon",
"theme.blog.paginator.newerEntries": "Uuemad sissekanded",
"theme.blog.paginator.olderEntries": "Vanemad sissekanded",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Redigeeri seda lehte",
"theme.common.headingLinkTitle": "Link {heading}",
"theme.common.skipToMainContent": "Liigu peamise sisu juurde",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "See leht ei ole avalik. Otsingumootorid ei indekseeri seda. Sellele lehele pääseb ainult lingiga ligi.",
"theme.contentVisibility.unlistedBanner.title": "avalikustamata leht",
"theme.docs.DocCard.categoryDescription.plurals": "1 ese|{count} eset",
"theme.docs.breadcrumbs.home": "Koduleht",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versioonid",
"theme.tags.tagsListLabel": "Märked:",
"theme.tags.tagsPageLink": "Näaita Kõiki Märkeid",
"theme.tags.tagsPageTitle": "Märked",
"theme.unlistedContent.message": "See leht ei ole avalik. Otsingumootorid ei indekseeri seda. Sellele lehele pääseb ainult lingiga ligi.",
"theme.unlistedContent.title": "avalikustamata leht"
"theme.tags.tagsPageTitle": "Märked"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "هشدار",
"theme.blog.archive.description": "آرشیو",
"theme.blog.archive.title": "آرشیو",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "کنترل لیست مطالب وبلاگ",
"theme.blog.paginator.newerEntries": "مطالب جدید‌تر",
"theme.blog.paginator.olderEntries": "مطالب قدیمی‌تر",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "ویرایش مطالب این صفحه",
"theme.common.headingLinkTitle": "لینک مستقیم به {heading}",
"theme.common.skipToMainContent": "پرش به مطلب اصلی",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "این صفحه فهرست نشده است. موتورهای جستجو آن را ایندکس نمی کنند و فقط کاربرانی که لینک مستقیم دارند می توانند به آن دسترسی داشته باشند.",
"theme.contentVisibility.unlistedBanner.title": "صفحه فهرست نشده",
"theme.docs.DocCard.categoryDescription.plurals": "{count} مورد",
"theme.docs.breadcrumbs.home": "صفحه اصلی",
"theme.docs.breadcrumbs.navAriaLabel": "نشانگر صفحات",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "نسخه‌ها",
"theme.tags.tagsListLabel": "برچسب‌ها:",
"theme.tags.tagsPageLink": "مشاهده تمام برچسب‌ها",
"theme.tags.tagsPageTitle": "برچسب‌ها",
"theme.unlistedContent.message": "این صفحه فهرست نشده است. موتورهای جستجو آن را ایندکس نمی کنند و فقط کاربرانی که لینک مستقیم دارند می توانند به آن دسترسی داشته باشند.",
"theme.unlistedContent.title": "صفحه فهرست نشده"
"theme.tags.tagsPageTitle": "برچسب‌ها"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Nabegasyón para sa pahina na listahan ng blog",
"theme.blog.paginator.newerEntries": "Mas bagong mga éntri",
"theme.blog.paginator.olderEntries": "Mas lumang mga éntri",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "I-edit ang page",
"theme.common.headingLinkTitle": "Direktang link patungo sa {heading}",
"theme.common.skipToMainContent": "Lumaktaw patungo sa pangunahing content",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Mga Tag:",
"theme.tags.tagsPageLink": "Tingnan Lahat ng mga Tag",
"theme.tags.tagsPageTitle": "Mga Tag",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "Mga Tag"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "attention",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Pagination de la liste des articles du blog",
"theme.blog.paginator.newerEntries": "Nouvelles entrées",
"theme.blog.paginator.olderEntries": "Anciennes entrées",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Éditer cette page",
"theme.common.headingLinkTitle": "Lien direct vers {heading}",
"theme.common.skipToMainContent": "Aller au contenu principal",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Cette page n'est pas répertoriée. Les moteurs de recherche ne l'indexeront pas, et seuls les utilisateurs ayant un lien direct peuvent y accéder.",
"theme.contentVisibility.unlistedBanner.title": "Page non répertoriée",
"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",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tags :",
"theme.tags.tagsPageLink": "Voir tous les tags",
"theme.tags.tagsPageTitle": "Tags",
"theme.unlistedContent.message": "Cette page n'est pas répertoriée. Les moteurs de recherche ne l'indexeront pas, et seuls les utilisateurs ayant un lien direct peuvent y accéder.",
"theme.unlistedContent.title": "Page non répertoriée"
"theme.tags.tagsPageTitle": "Tags"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "רשימת דפי הבלוג",
"theme.blog.paginator.newerEntries": "הכי חדש",
"theme.blog.paginator.olderEntries": "ישן יותר",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "ערוך דף זה",
"theme.common.headingLinkTitle": "קישור ישיר אל {heading}",
"theme.common.skipToMainContent": "דלג לתוכן הראשי",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "תגיות:",
"theme.tags.tagsPageLink": "כל התגיות",
"theme.tags.tagsPageTitle": "תגיות",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "תגיות"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archive",
"theme.blog.archive.title": "Archive",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "ब्लॉग सूची पेज नेविगेशन",
"theme.blog.paginator.newerEntries": "नए एंट्रीज़",
"theme.blog.paginator.olderEntries": "पुराने एंट्रीज़",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "इस पेज को बदलें",
"theme.common.headingLinkTitle": "{heading} का सीधा लिंक",
"theme.common.skipToMainContent": "मुख्य कंटेंट तक स्किप करें",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.contentVisibility.unlistedBanner.title": "Unlisted page",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Home page",
"theme.docs.breadcrumbs.navAriaLabel": "Breadcrumbs",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "टैग:",
"theme.tags.tagsPageLink": "सारे टैग देखें",
"theme.tags.tagsPageTitle": "टैग",
"theme.unlistedContent.message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"theme.unlistedContent.title": "Unlisted page"
"theme.tags.tagsPageTitle": "टैग"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "vigyázat",
"theme.blog.archive.description": "Archívum",
"theme.blog.archive.title": "Archívum",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Bloglista oldalának navigációja",
"theme.blog.paginator.newerEntries": "Újabb bejegyzések",
"theme.blog.paginator.olderEntries": "Régebbi bejegyzések",
@ -40,6 +43,10 @@
"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.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Ez az oldal nem nyilvános. A keresőmotorok nem indexelik, és csak a közvetlen hivatkozással rendelkező felhasználók érhetik el.",
"theme.contentVisibility.unlistedBanner.title": "Nem nyilvános oldal",
"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",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Verziók",
"theme.tags.tagsListLabel": "Címkék:",
"theme.tags.tagsPageLink": "Összes címke megtekintése",
"theme.tags.tagsPageTitle": "Címkék",
"theme.unlistedContent.message": "Ez az oldal nem nyilvános. A keresőmotorok nem indexelik, és csak a közvetlen hivatkozással rendelkező felhasználók érhetik el.",
"theme.unlistedContent.title": "Nem nyilvános oldal"
"theme.tags.tagsPageTitle": "Címkék"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "peringatan",
"theme.blog.archive.description": "Arsip",
"theme.blog.archive.title": "Arsip",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Navigasi entri blog",
"theme.blog.paginator.newerEntries": "Entri lebih baru",
"theme.blog.paginator.olderEntries": "Entri lebih lama",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Sunting halaman ini",
"theme.common.headingLinkTitle": "Taut langsung ke {heading}",
"theme.common.skipToMainContent": "Lewati ke konten utama",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Halaman ini tidak terdaftar. Mesin pencari tidak akan mengindeksnya, dan hanya pengguna yang memiliki tautan langsung yang dapat mengaksesnya.",
"theme.contentVisibility.unlistedBanner.title": "Halaman tak terdaftar",
"theme.docs.DocCard.categoryDescription.plurals": "1 butir|{count} butir",
"theme.docs.breadcrumbs.home": "Halaman utama",
"theme.docs.breadcrumbs.navAriaLabel": "Runut navigasi",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versi",
"theme.tags.tagsListLabel": "Tag:",
"theme.tags.tagsPageLink": "Lihat Semua Tag",
"theme.tags.tagsPageTitle": "Tag",
"theme.unlistedContent.message": "Halaman ini tidak terdaftar. Mesin pencari tidak akan mengindeksnya, dan hanya pengguna yang memiliki tautan langsung yang dapat mengaksesnya.",
"theme.unlistedContent.title": "Halaman tak terdaftar"
"theme.tags.tagsPageTitle": "Tag"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "aðvörun",
"theme.blog.archive.description": "Skjalasafn",
"theme.blog.archive.title": "Skjalasafn",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Blogg listsíðu yfirlit",
"theme.blog.paginator.newerEntries": "Nýrri færslur",
"theme.blog.paginator.olderEntries": "Eldri færslur",
@ -40,7 +43,12 @@
"theme.common.editThisPage": "Breyttu þessari síðu",
"theme.common.headingLinkTitle": "Beinn hlekkur að {heading}",
"theme.common.skipToMainContent": "Hoppa yfir á aðal efni",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Þessi síða er ólistuð. Leitarvélar munu ekki skrá hana, eingöngu notendur með beinan hlekk geta opnað hana.",
"theme.contentVisibility.unlistedBanner.title": "Óskráð síða",
"theme.docs.DocCard.categoryDescription": "{count} atriði",
"theme.docs.DocCard.categoryDescription.plurals": "1 item|{count} items",
"theme.docs.breadcrumbs.home": "Heimasíða",
"theme.docs.breadcrumbs.navAriaLabel": "Brauðteningar",
"theme.docs.paginator.navAriaLabel": "Skjala síður",
@ -68,7 +76,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Útgáfur",
"theme.tags.tagsListLabel": "Merki:",
"theme.tags.tagsPageLink": "Skoða Öll Merki",
"theme.tags.tagsPageTitle": "Merki",
"theme.unlistedContent.message": "Þessi síða er ólistuð. Leitarvélar munu ekki skrá hana, eingöngu notendur með beinan hlekk geta opnað hana.",
"theme.unlistedContent.title": "Óskráð síða"
"theme.tags.tagsPageTitle": "Merki"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "warning",
"theme.blog.archive.description": "Archivio",
"theme.blog.archive.title": "Archivio",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "Navigazione nella pagina dei post del blog ",
"theme.blog.paginator.newerEntries": "Post più recenti",
"theme.blog.paginator.olderEntries": "Post più vecchi",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "Modifica questa pagina",
"theme.common.headingLinkTitle": "Link diretto a {heading}",
"theme.common.skipToMainContent": "Passa al contenuto principale",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "Questa pagina non è in elenco. I motori di ricerca non lo indicheranno e solo gli utenti con collegamento diretto possono accedervi.",
"theme.contentVisibility.unlistedBanner.title": "Pagina non in elenco",
"theme.docs.DocCard.categoryDescription.plurals": "1 elemento|{count} elementi",
"theme.docs.breadcrumbs.home": "Pagina principale",
"theme.docs.breadcrumbs.navAriaLabel": "Briciole di pane",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "Versioni",
"theme.tags.tagsListLabel": "Etichette:",
"theme.tags.tagsPageLink": "Guarda tutte le etichette",
"theme.tags.tagsPageTitle": "Etichette",
"theme.unlistedContent.message": "Questa pagina non è in elenco. I motori di ricerca non lo indicheranno e solo gli utenti con collegamento diretto possono accedervi.",
"theme.unlistedContent.title": "Pagina non in elenco"
"theme.tags.tagsPageTitle": "Etichette"
}

View file

@ -22,6 +22,9 @@
"theme.admonition.warning": "警告",
"theme.blog.archive.description": "アーカイブ",
"theme.blog.archive.title": "アーカイブ",
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
"theme.blog.authorsList.pageTitle": "Authors",
"theme.blog.authorsList.viewAll": "View All Authors",
"theme.blog.paginator.navAriaLabel": "ブログ記事一覧のナビゲーション",
"theme.blog.paginator.newerEntries": "新しい記事",
"theme.blog.paginator.olderEntries": "過去の記事",
@ -40,6 +43,10 @@
"theme.common.editThisPage": "このページを編集",
"theme.common.headingLinkTitle": "{heading} への直接リンク",
"theme.common.skipToMainContent": "メインコンテンツまでスキップ",
"theme.contentVisibility.draftBanner.message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"theme.contentVisibility.draftBanner.title": "Draft page",
"theme.contentVisibility.unlistedBanner.message": "このページは非公開です。 検索対象外となり、このページのリンクに直接アクセスできるユーザーのみに公開されます。",
"theme.contentVisibility.unlistedBanner.title": "非公開のページ",
"theme.docs.DocCard.categoryDescription.plurals": "{count}項目",
"theme.docs.breadcrumbs.home": "ホームページ",
"theme.docs.breadcrumbs.navAriaLabel": "パンくずリストのナビゲーション",
@ -68,7 +75,5 @@
"theme.navbar.mobileVersionsDropdown.label": "他のバージョン",
"theme.tags.tagsListLabel": "タグ:",
"theme.tags.tagsPageLink": "全てのタグを見る",
"theme.tags.tagsPageTitle": "タグ",
"theme.unlistedContent.message": "このページは非公開です。 検索対象外となり、このページのリンクに直接アクセスできるユーザーのみに公開されます。",
"theme.unlistedContent.title": "非公開のページ"
"theme.tags.tagsPageTitle": "タグ"
}

Some files were not shown because too many files have changed in this diff Show more