feat(docs,blog,pages): add support for "unlisted" front matter - hide md content in production (#8004)

Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Jody Heavener 2022-11-03 06:31:41 -07:00 committed by GitHub
parent 7a023a2c41
commit 683ba3d2a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
131 changed files with 2449 additions and 303 deletions

View file

@ -0,0 +1,9 @@
---
slug: /another/blog-with-tags-unlisted
title: Another Blog With Tag - unlisted
date: 2020-08-19
tags: [unlisted]
unlisted: true
---
with tag

View file

@ -0,0 +1,6 @@
---
date: 2020-02-27
unlisted: true
---
this post is unlisted

File diff suppressed because one or more lines are too long

View file

@ -30,6 +30,7 @@ exports[`blog plugin works on blog tags without pagination 1`] = `
},
],
"permalink": "/blog/tags/tag-1",
"unlisted": false,
},
"/blog/tags/tag-2": {
"items": [
@ -57,6 +58,33 @@ exports[`blog plugin works on blog tags without pagination 1`] = `
},
],
"permalink": "/blog/tags/tag-2",
"unlisted": false,
},
"/blog/tags/unlisted": {
"items": [
"/another/blog-with-tags-unlisted",
],
"label": "unlisted",
"pages": [
{
"items": [
"/another/blog-with-tags-unlisted",
],
"metadata": {
"blogDescription": "Blog",
"blogTitle": "Blog",
"nextPage": undefined,
"page": 1,
"permalink": "/blog/tags/unlisted",
"postsPerPage": 1,
"previousPage": undefined,
"totalCount": 1,
"totalPages": 1,
},
},
],
"permalink": "/blog/tags/unlisted",
"unlisted": false,
},
}
`;
@ -106,6 +134,7 @@ exports[`blog plugin works with blog tags 1`] = `
},
],
"permalink": "/blog/tags/tag-1",
"unlisted": false,
},
"/blog/tags/tag-2": {
"items": [
@ -133,6 +162,33 @@ exports[`blog plugin works with blog tags 1`] = `
},
],
"permalink": "/blog/tags/tag-2",
"unlisted": false,
},
"/blog/tags/unlisted": {
"items": [
"/another/blog-with-tags-unlisted",
],
"label": "unlisted",
"pages": [
{
"items": [
"/another/blog-with-tags-unlisted",
],
"metadata": {
"blogDescription": "Blog",
"blogTitle": "Blog",
"nextPage": undefined,
"page": 1,
"permalink": "/blog/tags/unlisted",
"postsPerPage": 2,
"previousPage": undefined,
"totalCount": 1,
"totalPages": 1,
},
},
],
"permalink": "/blog/tags/unlisted",
"unlisted": false,
},
}
`;

View file

@ -54,7 +54,7 @@ async function testGenerateFeeds(
);
await createBlogFeedFiles({
blogPosts: blogPosts.filter((post) => !post.metadata.frontMatter.draft),
blogPosts,
options,
siteConfig: context.siteConfig,
outDir: context.outDir,

View file

@ -12,7 +12,7 @@ import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
// TODO this abstraction reduce verbosity but it makes it harder to debug
// It would be preferable to just expose helper methods
function testField(params: {
fieldName: keyof BlogPostFrontMatter;
prefix: string;
validFrontMatters: BlogPostFrontMatter[];
convertibleFrontMatter?: [
ConvertibleFrontMatter: {[key: string]: unknown},
@ -23,7 +23,7 @@ function testField(params: {
ErrorMessage: string,
][];
}) {
describe(`"${params.fieldName}" field`, () => {
describe(`"${params.prefix}" field`, () => {
it('accept valid values', () => {
params.validFrontMatters.forEach((frontMatter) => {
expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter);
@ -44,15 +44,12 @@ function testField(params: {
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
try {
validateBlogPostFrontMatter(frontMatter);
// eslint-disable-next-line jest/no-jasmine-globals
fail(
new Error(
`Blog front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
),
throw new Error(
`Blog front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
);
} catch (err) {
// eslint-disable-next-line jest/no-conditional-expect
@ -79,7 +76,7 @@ describe('validateBlogPostFrontMatter', () => {
describe('validateBlogPostFrontMatter description', () => {
testField({
fieldName: 'description',
prefix: 'description',
validFrontMatters: [
// See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398
{description: ''},
@ -90,7 +87,7 @@ describe('validateBlogPostFrontMatter description', () => {
describe('validateBlogPostFrontMatter title', () => {
testField({
fieldName: 'title',
prefix: 'title',
validFrontMatters: [
// See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398
{title: ''},
@ -101,7 +98,7 @@ describe('validateBlogPostFrontMatter title', () => {
describe('validateBlogPostFrontMatter id', () => {
testField({
fieldName: 'id',
prefix: 'id',
validFrontMatters: [{id: '123'}, {id: 'id'}],
invalidFrontMatters: [[{id: ''}, 'not allowed to be empty']],
});
@ -132,7 +129,7 @@ describe('validateBlogPostFrontMatter handles legacy/new author front matter', (
describe('validateBlogPostFrontMatter author', () => {
testField({
fieldName: 'author',
prefix: 'author',
validFrontMatters: [{author: '123'}, {author: 'author'}],
invalidFrontMatters: [[{author: ''}, 'not allowed to be empty']],
});
@ -140,7 +137,7 @@ describe('validateBlogPostFrontMatter author', () => {
describe('validateBlogPostFrontMatter author_title', () => {
testField({
fieldName: 'author_title',
prefix: 'author_title',
validFrontMatters: [
{author: '123', author_title: '123'},
{author: '123', author_title: 'author_title'},
@ -149,7 +146,7 @@ describe('validateBlogPostFrontMatter author_title', () => {
});
testField({
fieldName: 'authorTitle',
prefix: 'authorTitle',
validFrontMatters: [{authorTitle: '123'}, {authorTitle: 'authorTitle'}],
invalidFrontMatters: [[{authorTitle: ''}, 'not allowed to be empty']],
});
@ -157,7 +154,7 @@ describe('validateBlogPostFrontMatter author_title', () => {
describe('validateBlogPostFrontMatter author_url', () => {
testField({
fieldName: 'author_url',
prefix: 'author_url',
validFrontMatters: [
{author_url: 'https://docusaurus.io'},
{author_url: '../../relative'},
@ -172,7 +169,7 @@ describe('validateBlogPostFrontMatter author_url', () => {
});
testField({
fieldName: 'authorURL',
prefix: 'authorURL',
validFrontMatters: [
{authorURL: 'https://docusaurus.io'},
{authorURL: '../../relative'},
@ -190,7 +187,7 @@ describe('validateBlogPostFrontMatter author_url', () => {
describe('validateBlogPostFrontMatter author_image_url', () => {
testField({
fieldName: 'author_image_url',
prefix: 'author_image_url',
validFrontMatters: [
{author_image_url: 'https://docusaurus.io/asset/image.png'},
{author_image_url: '../../relative'},
@ -205,7 +202,7 @@ describe('validateBlogPostFrontMatter author_image_url', () => {
});
testField({
fieldName: 'authorImageURL',
prefix: 'authorImageURL',
validFrontMatters: [
{authorImageURL: 'https://docusaurus.io/asset/image.png'},
{authorImageURL: '../../relative'},
@ -222,7 +219,7 @@ describe('validateBlogPostFrontMatter author_image_url', () => {
describe('validateBlogPostFrontMatter authors', () => {
testField({
fieldName: 'author',
prefix: 'author',
validFrontMatters: [
{authors: []},
{authors: 'authorKey'},
@ -270,7 +267,7 @@ describe('validateBlogPostFrontMatter authors', () => {
describe('validateBlogPostFrontMatter slug', () => {
testField({
fieldName: 'slug',
prefix: 'slug',
validFrontMatters: [
{slug: 'blog/'},
{slug: '/blog'},
@ -287,7 +284,7 @@ describe('validateBlogPostFrontMatter slug', () => {
describe('validateBlogPostFrontMatter image', () => {
testField({
fieldName: 'image',
prefix: 'image',
validFrontMatters: [
{image: 'https://docusaurus.io/image.png'},
{image: 'blog/'},
@ -307,7 +304,7 @@ describe('validateBlogPostFrontMatter image', () => {
describe('validateBlogPostFrontMatter tags', () => {
testField({
fieldName: 'tags',
prefix: 'tags',
validFrontMatters: [
{tags: []},
{tags: ['hello']},
@ -335,7 +332,7 @@ describe('validateBlogPostFrontMatter tags', () => {
describe('validateBlogPostFrontMatter keywords', () => {
testField({
fieldName: 'keywords',
prefix: 'keywords',
validFrontMatters: [
{keywords: ['hello']},
{keywords: ['hello', 'world']},
@ -352,7 +349,7 @@ describe('validateBlogPostFrontMatter keywords', () => {
describe('validateBlogPostFrontMatter draft', () => {
testField({
fieldName: 'draft',
prefix: 'draft',
validFrontMatters: [{draft: true}, {draft: false}],
convertibleFrontMatter: [
[{draft: 'true'}, {draft: true}],
@ -365,9 +362,43 @@ describe('validateBlogPostFrontMatter draft', () => {
});
});
describe('validateBlogPostFrontMatter unlisted', () => {
testField({
prefix: 'unlisted',
validFrontMatters: [{unlisted: true}, {unlisted: false}],
convertibleFrontMatter: [
[{unlisted: 'true'}, {unlisted: true}],
[{unlisted: 'false'}, {unlisted: false}],
],
invalidFrontMatters: [
[{unlisted: 'yes'}, 'must be a boolean'],
[{unlisted: 'no'}, 'must be a boolean'],
],
});
});
describe('validateDocFrontMatter draft XOR unlisted', () => {
testField({
prefix: 'draft XOR unlisted',
validFrontMatters: [
{draft: false},
{unlisted: false},
{draft: false, unlisted: false},
{draft: true, unlisted: false},
{draft: false, unlisted: true},
],
invalidFrontMatters: [
[
{draft: true, unlisted: true},
"Can't be draft and unlisted at the same time.",
],
],
});
});
describe('validateBlogPostFrontMatter hide_table_of_contents', () => {
testField({
fieldName: 'hide_table_of_contents',
prefix: 'hide_table_of_contents',
validFrontMatters: [
{hide_table_of_contents: true},
{hide_table_of_contents: false},
@ -385,7 +416,7 @@ describe('validateBlogPostFrontMatter hide_table_of_contents', () => {
describe('validateBlogPostFrontMatter date', () => {
testField({
fieldName: 'date',
prefix: 'date',
validFrontMatters: [
{date: new Date('2020-01-01')},
{date: '2020-01-01'},

View file

@ -172,6 +172,7 @@ describe('blog plugin', () => {
title: 'Happy 1st Birthday Slash! (translated)',
},
hasTruncateMarker: false,
unlisted: false,
});
expect(
@ -215,6 +216,7 @@ describe('blog plugin', () => {
title: 'date-matter',
},
hasTruncateMarker: false,
unlisted: false,
});
expect({
@ -252,6 +254,7 @@ describe('blog plugin', () => {
},
],
hasTruncateMarker: false,
unlisted: false,
});
expect({
@ -289,6 +292,7 @@ describe('blog plugin', () => {
},
tags: [],
hasTruncateMarker: false,
unlisted: false,
});
expect({
@ -314,13 +318,14 @@ describe('blog plugin', () => {
title: 'date-matter',
},
hasTruncateMarker: false,
unlisted: false,
});
});
it('builds simple website blog with localized dates', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const blogPostsFrench = await getBlogPosts(siteDir, {}, getI18n('fr'));
expect(blogPostsFrench).toHaveLength(8);
expect(blogPostsFrench).toHaveLength(9);
expect(blogPostsFrench[0]!.metadata.formattedDate).toMatchInlineSnapshot(
`"6 mars 2021"`,
);
@ -337,13 +342,13 @@ describe('blog plugin', () => {
`"27 février 2020"`,
);
expect(blogPostsFrench[5]!.metadata.formattedDate).toMatchInlineSnapshot(
`"2 janvier 2019"`,
`"27 février 2020"`,
);
expect(blogPostsFrench[6]!.metadata.formattedDate).toMatchInlineSnapshot(
`"1 janvier 2019"`,
`"2 janvier 2019"`,
);
expect(blogPostsFrench[7]!.metadata.formattedDate).toMatchInlineSnapshot(
`"14 décembre 2018"`,
`"1 janvier 2019"`,
);
});
@ -372,7 +377,7 @@ describe('blog plugin', () => {
expect(blogPost.metadata.editUrl).toEqual(hardcodedEditUrl);
});
expect(editUrlFunction).toHaveBeenCalledTimes(8);
expect(editUrlFunction).toHaveBeenCalledTimes(9);
expect(editUrlFunction).toHaveBeenCalledWith({
blogDirPath: 'blog',
@ -471,6 +476,7 @@ describe('blog plugin', () => {
prevItem: undefined,
nextItem: undefined,
hasTruncateMarker: false,
unlisted: false,
});
});
@ -495,7 +501,7 @@ describe('blog plugin', () => {
postsPerPage: 2,
});
expect(Object.keys(blogTags)).toHaveLength(2);
expect(Object.keys(blogTags)).toHaveLength(3);
expect(blogTags).toMatchSnapshot();
});

View file

@ -0,0 +1,70 @@
/**
* 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 {toTagsProp} from '../props';
describe('toTagsProp', () => {
type Tags = Parameters<typeof toTagsProp>[0]['blogTags'];
type Tag = Tags[number];
const tag1: Tag = {
label: 'Tag 1',
permalink: '/tag1',
items: ['item1', 'item2'],
pages: [],
unlisted: false,
};
const tag2: Tag = {
label: 'Tag 2',
permalink: '/tag2',
items: ['item3'],
pages: [],
unlisted: false,
};
function testTags(...tags: Tag[]) {
const blogTags: Tags = {};
tags.forEach((tag) => {
blogTags[tag.permalink] = tag;
});
return toTagsProp({blogTags});
}
it('works', () => {
expect(testTags(tag1, tag2)).toEqual([
{
count: tag1.items.length,
label: tag1.label,
permalink: tag1.permalink,
},
{
count: tag2.items.length,
label: tag2.label,
permalink: tag2.permalink,
},
]);
});
it('filters unlisted tags', () => {
expect(testTags(tag1, {...tag2, unlisted: true})).toEqual([
{
count: tag1.items.length,
label: tag1.label,
permalink: tag1.permalink,
},
]);
expect(testTags({...tag1, unlisted: true}, tag2)).toEqual([
{
count: tag2.items.length,
label: tag2.label,
permalink: tag2.permalink,
},
]);
});
});

View file

@ -21,8 +21,11 @@ import {
Globby,
normalizeFrontMatterTags,
groupTaggedItems,
getTagVisibility,
getFileCommitDate,
getContentPathList,
isUnlisted,
isDraft,
} from '@docusaurus/utils';
import {validateBlogPostFrontMatter} from './frontMatter';
import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
@ -96,6 +99,10 @@ export function paginateBlogPosts({
return pages;
}
export function shouldBeListed(blogPost: BlogPost): boolean {
return !blogPost.metadata.unlisted;
}
export function getBlogTags({
blogPosts,
...params
@ -109,17 +116,23 @@ export function getBlogTags({
blogPosts,
(blogPost) => blogPost.metadata.tags,
);
return _.mapValues(groups, ({tag, items: tagBlogPosts}) => ({
label: tag.label,
items: tagBlogPosts.map((item) => item.id),
permalink: tag.permalink,
pages: paginateBlogPosts({
blogPosts: tagBlogPosts,
basePageUrl: tag.permalink,
...params,
}),
}));
return _.mapValues(groups, ({tag, items: tagBlogPosts}) => {
const tagVisibility = getTagVisibility({
items: tagBlogPosts,
isUnlisted: (item) => item.metadata.unlisted,
});
return {
label: tag.label,
items: tagVisibility.listedItems.map((item) => item.id),
permalink: tag.permalink,
pages: paginateBlogPosts({
blogPosts: tagVisibility.listedItems,
basePageUrl: tag.permalink,
...params,
}),
unlisted: tagVisibility.unlisted,
};
});
}
const DATE_FILENAME_REGEX =
@ -219,7 +232,10 @@ async function processBlogSourceFile(
const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
if (frontMatter.draft && process.env.NODE_ENV === 'production') {
const draft = isDraft({frontMatter});
const unlisted = isUnlisted({frontMatter});
if (draft) {
return undefined;
}
@ -326,6 +342,7 @@ async function processBlogSourceFile(
hasTruncateMarker: truncateMarker.test(content),
authors,
frontMatter,
unlisted,
},
content,
};
@ -352,23 +369,25 @@ export async function generateBlogPosts(
authorsMapPath: options.authorsMapPath,
});
async function doProcessBlogSourceFile(blogSourceFile: string) {
try {
return await processBlogSourceFile(
blogSourceFile,
contentPaths,
context,
options,
authorsMap,
);
} catch (err) {
throw new Error(
`Processing of blog source file path=${blogSourceFile} failed.`,
{cause: err as Error},
);
}
}
const blogPosts = (
await Promise.all(
blogSourceFiles.map(async (blogSourceFile: string) => {
try {
return await processBlogSourceFile(
blogSourceFile,
contentPaths,
context,
options,
authorsMap,
);
} catch (err) {
logger.error`Processing of blog source file path=${blogSourceFile} failed.`;
throw err;
}
}),
)
await Promise.all(blogSourceFiles.map(doProcessBlogSourceFile))
).filter(Boolean) as BlogPost[];
blogPosts.sort(

View file

@ -133,8 +133,15 @@ async function createBlogFeedFile({
}
}
function shouldBeInFeed(blogPost: BlogPost): boolean {
const excluded =
blogPost.metadata.frontMatter.draft ||
blogPost.metadata.frontMatter.unlisted;
return !excluded;
}
export async function createBlogFeedFiles({
blogPosts,
blogPosts: allBlogPosts,
options,
siteConfig,
outDir,
@ -146,6 +153,8 @@ export async function createBlogFeedFiles({
outDir: string;
locale: string;
}): Promise<void> {
const blogPosts = allBlogPosts.filter(shouldBeInFeed);
const feed = await generateBlogFeed({
blogPosts,
options,

View file

@ -11,6 +11,7 @@ import {
validateFrontMatter,
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
ContentVisibilitySchema,
} from '@docusaurus/utils-validation';
import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
@ -32,7 +33,6 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
title: Joi.string().allow(''),
description: Joi.string().allow(''),
tags: FrontMatterTagsSchema,
draft: Joi.boolean(),
date: Joi.date().raw(),
// New multi-authors front matter:
@ -69,10 +69,12 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
hide_table_of_contents: Joi.boolean(),
...FrontMatterTOCHeadingLevels,
}).messages({
'deprecate.error':
'{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',
});
})
.messages({
'deprecate.error':
'{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',
})
.concat(ContentVisibilitySchema);
export function validateBlogPostFrontMatter(frontMatter: {
[key: string]: unknown;

View file

@ -18,19 +18,19 @@ import {
getContentPathList,
getDataFilePath,
DEFAULT_PLUGIN_ID,
type TagsListItem,
type TagModule,
} from '@docusaurus/utils';
import {
generateBlogPosts,
getSourceToPermalink,
getBlogTags,
paginateBlogPosts,
shouldBeListed,
} from './blogUtils';
import footnoteIDFixer from './remark/footnoteIDFixer';
import {translateContent, getTranslationFiles} from './translations';
import {createBlogFeedFiles} from './feed';
import {toTagProp, toTagsProp} from './props';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types';
import type {
@ -112,6 +112,7 @@ export default async function pluginContentBlog(
const baseBlogUrl = normalizeUrl([baseUrl, routeBasePath]);
const blogTagsListPath = normalizeUrl([baseBlogUrl, tagsBasePath]);
const blogPosts = await generateBlogPosts(contentPaths, context, options);
const listedBlogPosts = blogPosts.filter(shouldBeListed);
if (!blogPosts.length) {
return {
@ -125,8 +126,8 @@ export default async function pluginContentBlog(
}
// Colocate next and prev metadata.
blogPosts.forEach((blogPost, index) => {
const prevItem = index > 0 ? blogPosts[index - 1] : null;
listedBlogPosts.forEach((blogPost, index) => {
const prevItem = index > 0 ? listedBlogPosts[index - 1] : null;
if (prevItem) {
blogPost.metadata.prevItem = {
title: prevItem.metadata.title,
@ -135,7 +136,9 @@ export default async function pluginContentBlog(
}
const nextItem =
index < blogPosts.length - 1 ? blogPosts[index + 1] : null;
index < listedBlogPosts.length - 1
? listedBlogPosts[index + 1]
: null;
if (nextItem) {
blogPost.metadata.nextItem = {
title: nextItem.metadata.title,
@ -145,7 +148,7 @@ export default async function pluginContentBlog(
});
const blogListPaginated: BlogPaginated[] = paginateBlogPosts({
blogPosts,
blogPosts: listedBlogPosts,
blogTitle,
blogDescription,
postsPerPageOption,
@ -242,6 +245,7 @@ export default async function pluginContentBlog(
items: sidebarBlogPosts.map((blogPost) => ({
title: blogPost.metadata.title,
permalink: blogPost.metadata.permalink,
unlisted: blogPost.metadata.unlisted,
})),
},
null,
@ -303,17 +307,10 @@ export default async function pluginContentBlog(
}
async function createTagsListPage() {
const tagsProp: TagsListItem[] = Object.values(blogTags).map((tag) => ({
label: tag.label,
permalink: tag.permalink,
count: tag.items.length,
}));
const tagsPropPath = await createData(
`${docuHash(`${blogTagsListPath}-tags`)}.json`,
JSON.stringify(tagsProp, null, 2),
JSON.stringify(toTagsProp({blogTags}), null, 2),
);
addRoute({
path: blogTagsListPath,
component: blogTagsListComponent,
@ -329,15 +326,9 @@ export default async function pluginContentBlog(
await Promise.all(
tag.pages.map(async (blogPaginated) => {
const {metadata, items} = blogPaginated;
const tagProp: TagModule = {
label: tag.label,
permalink: tag.permalink,
allTagsPath: blogTagsListPath,
count: tag.items.length,
};
const tagPropPath = await createData(
`${docuHash(metadata.permalink)}.json`,
JSON.stringify(tagProp, null, 2),
JSON.stringify(toTagProp({tag, blogTagsListPath}), null, 2),
);
const listMetadataPath = await createData(

View file

@ -90,6 +90,10 @@ declare module '@docusaurus/plugin-content-blog' {
* Marks the post as draft and excludes it from the production build.
*/
draft?: boolean;
/**
* Marks the post as unlisted and visibly hides it unless directly accessed.
*/
unlisted?: boolean;
/**
* Will override the default publish date inferred from git/filename. Yaml
* only converts standard yyyy-MM-dd format to dates, so this may stay as a
@ -222,6 +226,10 @@ declare module '@docusaurus/plugin-content-blog' {
readonly frontMatter: BlogPostFrontMatter & {[key: string]: unknown};
/** Tags, normalized. */
readonly tags: Tag[];
/**
* Marks the post as unlisted and visibly hides it unless directly accessed.
*/
readonly unlisted: boolean;
};
/**
* @returns The edit URL that's directly plugged into metadata.
@ -407,9 +415,15 @@ declare module '@docusaurus/plugin-content-blog' {
}
>;
export type BlogSidebarItem = {
title: string;
permalink: string;
unlisted: boolean;
};
export type BlogSidebar = {
title: string;
items: {title: string; permalink: string}[];
items: BlogSidebarItem[];
};
export type BlogContent = {
@ -428,6 +442,7 @@ declare module '@docusaurus/plugin-content-blog' {
/** Blog post permalinks. */
items: string[];
pages: BlogPaginated[];
unlisted: boolean;
};
export type BlogPost = {

View file

@ -0,0 +1,34 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {TagsListItem, TagModule} from '@docusaurus/utils';
import type {BlogTag, BlogTags} from '@docusaurus/plugin-content-blog';
export function toTagsProp({blogTags}: {blogTags: BlogTags}): TagsListItem[] {
return Object.values(blogTags)
.filter((tag) => !tag.unlisted)
.map((tag) => ({
label: tag.label,
permalink: tag.permalink,
count: tag.items.length,
}));
}
export function toTagProp({
blogTagsListPath,
tag,
}: {
blogTagsListPath: string;
tag: BlogTag;
}): TagModule {
return {
label: tag.label,
permalink: tag.permalink,
allTagsPath: blogTagsListPath,
count: tag.items.length,
unlisted: tag.unlisted,
};
}

View file

@ -0,0 +1,5 @@
---
unlisted: true
---
This is an unlisted document

View file

@ -1,5 +1,6 @@
---
custom_edit_url: null
pagination_next: doc-unlisted
---
Lorem ipsum.

View file

@ -0,0 +1,6 @@
---
id: unlisted-category-index
unlisted: true
---
This is an unlisted category index

View file

@ -0,0 +1,6 @@
---
id: unlisted-category-doc
unlisted: true
---
This is an unlisted category doc

View file

@ -4,7 +4,16 @@
{
"type": "category",
"label": "foo",
"items": ["foo/bar", "foo/baz"]
"items": ["foo/bar", "doc-unlisted", "foo/baz"]
},
{
"type": "category",
"label": "Unlisted category",
"link": {
"type": "doc",
"id": "unlisted-category/unlisted-category-index"
},
"items": ["unlisted-category/unlisted-category-doc"]
},
{
"type": "category",

View file

@ -10,11 +10,23 @@ exports[`docsVersion first time versioning 1`] = `
{
"items": [
"foo/bar",
"doc-unlisted",
"foo/baz",
],
"label": "foo",
"type": "category",
},
{
"items": [
"unlisted-category/unlisted-category-doc",
],
"label": "Unlisted category",
"link": {
"id": "unlisted-category/unlisted-category-index",
"type": "doc",
},
"type": "category",
},
{
"items": [
"rootAbsoluteSlug",

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`simple site custom pagination 1`] = `
exports[`simple site custom pagination - development 1`] = `
{
"pagination": [
{
@ -25,14 +25,25 @@ exports[`simple site custom pagination 1`] = `
{
"id": "doc-draft",
"next": {
"permalink": "/docs/foo/bar",
"title": "Bar",
"permalink": "/docs/doc-unlisted",
"title": "doc-unlisted",
},
"prev": {
"permalink": "/docs/doc with space",
"title": "Hoo hoo, if this path tricks you...",
},
},
{
"id": "doc-unlisted",
"next": {
"permalink": "/docs/foo/bar",
"title": "Bar",
},
"prev": {
"permalink": "/docs/doc-draft",
"title": "doc-draft",
},
},
{
"id": "foo/bar",
"next": undefined,
@ -74,9 +85,356 @@ exports[`simple site custom pagination 1`] = `
{
"id": "ipsum",
"next": {
"permalink": "/docs/doc-unlisted",
"title": "doc-unlisted",
},
"prev": {
"permalink": "/docs/",
"title": "Hello sidebar_label",
},
},
{
"id": "lastUpdateAuthorOnly",
"next": {
"permalink": "/docs/lastUpdateDateOnly",
"title": "Last Update Date Only",
},
"prev": {
"permalink": "/docs/ipsum",
"title": "ipsum",
},
},
{
"id": "lastUpdateDateOnly",
"next": {
"permalink": "/docs/lorem",
"title": "lorem",
},
"prev": {
"permalink": "/docs/lastUpdateAuthorOnly",
"title": "Last Update Author Only",
},
},
{
"id": "lorem",
"next": {
"permalink": "/docs/rootAbsoluteSlug",
"title": "rootAbsoluteSlug",
},
"prev": {
"permalink": "/docs/lastUpdateDateOnly",
"title": "Last Update Date Only",
},
},
{
"id": "rootAbsoluteSlug",
"next": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
"prev": {
"permalink": "/docs/foo/bazSlug.html",
"title": "baz pagination_label",
},
},
{
"id": "rootRelativeSlug",
"next": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
"prev": {
"permalink": "/docs/foo/bazSlug.html",
"title": "baz pagination_label",
},
},
{
"id": "rootResolvedSlug",
"next": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
"prev": {
"permalink": "/docs/foo/bazSlug.html",
"title": "baz pagination_label",
},
},
{
"id": "rootTryToEscapeSlug",
"next": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
"prev": {
"permalink": "/docs/foo/bazSlug.html",
"title": "baz pagination_label",
},
},
{
"id": "slugs/absoluteSlug",
"next": {
"permalink": "/docs/slugs/relativeSlug",
"title": "relativeSlug",
},
"prev": {
"permalink": "/docs/rootTryToEscapeSlug",
"title": "rootTryToEscapeSlug",
},
},
{
"id": "slugs/relativeSlug",
"next": {
"permalink": "/docs/slugs/hey/resolvedSlug",
"title": "resolvedSlug",
},
"prev": {
"permalink": "/docs/absoluteSlug",
"title": "absoluteSlug",
},
},
{
"id": "slugs/resolvedSlug",
"next": {
"permalink": "/docs/tryToEscapeSlug",
"title": "tryToEscapeSlug",
},
"prev": {
"permalink": "/docs/slugs/relativeSlug",
"title": "relativeSlug",
},
},
{
"id": "slugs/tryToEscapeSlug",
"next": {
"permalink": "/docs/unlisted-category/",
"title": "unlisted-category-index",
},
"prev": {
"permalink": "/docs/slugs/hey/resolvedSlug",
"title": "resolvedSlug",
},
},
{
"id": "unlisted-category/unlisted-category-doc",
"next": undefined,
"prev": {
"permalink": "/docs/unlisted-category/",
"title": "unlisted-category-index",
},
},
{
"id": "unlisted-category/unlisted-category-index",
"next": {
"permalink": "/docs/unlisted-category/unlisted-category-doc",
"title": "unlisted-category-doc",
},
"prev": {
"permalink": "/docs/tryToEscapeSlug",
"title": "tryToEscapeSlug",
},
},
],
"sidebars": {
"defaultSidebar": [
{
"id": "customLastUpdate",
"type": "doc",
},
{
"id": "doc with space",
"type": "doc",
},
{
"id": "doc-draft",
"type": "doc",
},
{
"id": "doc-unlisted",
"type": "doc",
},
{
"collapsed": false,
"collapsible": true,
"items": [
{
"id": "foo/bar",
"type": "doc",
},
{
"id": "foo/baz",
"type": "doc",
},
],
"label": "foo",
"link": undefined,
"type": "category",
},
{
"id": "headingAsTitle",
"type": "doc",
},
{
"id": "hello",
"label": "Hello sidebar_label",
"type": "doc",
},
{
"id": "ipsum",
"type": "doc",
},
{
"id": "lastUpdateAuthorOnly",
"type": "doc",
},
{
"id": "lastUpdateDateOnly",
"type": "doc",
},
{
"id": "lorem",
"type": "doc",
},
{
"id": "rootAbsoluteSlug",
"type": "doc",
},
{
"id": "rootRelativeSlug",
"type": "doc",
},
{
"id": "rootResolvedSlug",
"type": "doc",
},
{
"id": "rootTryToEscapeSlug",
"type": "doc",
},
{
"collapsed": false,
"collapsible": true,
"items": [
{
"id": "slugs/absoluteSlug",
"type": "doc",
},
{
"id": "slugs/relativeSlug",
"type": "doc",
},
{
"id": "slugs/resolvedSlug",
"type": "doc",
},
{
"id": "slugs/tryToEscapeSlug",
"type": "doc",
},
],
"label": "slugs",
"link": undefined,
"type": "category",
},
{
"collapsed": false,
"collapsible": true,
"items": [
{
"id": "unlisted-category/unlisted-category-doc",
"type": "doc",
},
],
"label": "unlisted-category-index",
"link": {
"id": "unlisted-category/unlisted-category-index",
"type": "doc",
},
"type": "category",
},
],
},
}
`;
exports[`simple site custom pagination - production 1`] = `
{
"pagination": [
{
"id": "customLastUpdate",
"next": {
"permalink": "/docs/doc with space",
"title": "Hoo hoo, if this path tricks you...",
},
"prev": undefined,
},
{
"id": "doc with space",
"next": {
"permalink": "/docs/doc-draft",
"title": "doc-draft",
},
"prev": {
"permalink": "/docs/customLastUpdate",
"title": "Custom Last Update",
},
},
{
"id": "doc-draft",
"next": {
"permalink": "/docs/foo/bar",
"title": "Bar",
},
"prev": {
"permalink": "/docs/doc with space",
"title": "Hoo hoo, if this path tricks you...",
},
},
{
"id": "doc-unlisted",
"next": undefined,
"prev": undefined,
},
{
"id": "foo/bar",
"next": undefined,
"prev": undefined,
},
{
"id": "foo/baz",
"next": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
"prev": {
"permalink": "/docs/foo/bar",
"title": "Bar",
},
},
{
"id": "headingAsTitle",
"next": {
"permalink": "/docs/",
"title": "Hello sidebar_label",
},
"prev": {
"permalink": "/docs/foo/bazSlug.html",
"title": "baz pagination_label",
},
},
{
"id": "hello",
"next": {
"permalink": "/docs/ipsum",
"title": "ipsum",
},
"prev": {
"permalink": "/docs/headingAsTitle",
"title": "My heading as title",
},
},
{
"id": "ipsum",
"next": undefined,
"prev": {
"permalink": "/docs/",
"title": "Hello sidebar_label",
@ -200,6 +558,16 @@ exports[`simple site custom pagination 1`] = `
"title": "resolvedSlug",
},
},
{
"id": "unlisted-category/unlisted-category-doc",
"next": undefined,
"prev": undefined,
},
{
"id": "unlisted-category/unlisted-category-index",
"next": undefined,
"prev": undefined,
},
],
"sidebars": {
"defaultSidebar": [
@ -215,6 +583,10 @@ exports[`simple site custom pagination 1`] = `
"id": "doc-draft",
"type": "doc",
},
{
"id": "doc-unlisted",
"type": "doc",
},
{
"collapsed": false,
"collapsible": true,
@ -298,6 +670,22 @@ exports[`simple site custom pagination 1`] = `
"link": undefined,
"type": "category",
},
{
"collapsed": false,
"collapsible": true,
"items": [
{
"id": "unlisted-category/unlisted-category-doc",
"type": "doc",
},
],
"label": "unlisted-category-index",
"link": {
"id": "unlisted-category/unlisted-category-index",
"type": "doc",
},
"type": "category",
},
],
},
}

View file

@ -13,6 +13,12 @@ exports[`toGlobalDataVersion generates the right docs, sidebars, and metadata 1`
"path": "/current/doc",
"sidebar": "tutorial",
},
{
"id": "docNoSidebarUnlisted",
"path": "/current/docNoSidebarUnlisted",
"sidebar": undefined,
"unlisted": true,
},
{
"id": "/current/generated",
"path": "/current/generated",

View file

@ -153,7 +153,7 @@ function createTestUtils({
versionMetadata,
context,
options,
env: 'production',
env,
}),
),
);
@ -173,11 +173,11 @@ function createTestUtils({
const sidebarsUtils = createSidebarsUtils(sidebars);
return {
pagination: addDocNavigation(
rawDocs,
pagination: addDocNavigation({
docs: rawDocs,
sidebarsUtils,
versionMetadata.sidebarFilePath as string,
).map((doc) => ({prev: doc.previous, next: doc.next, id: doc.id})),
sidebarFilePath: versionMetadata.sidebarFilePath as string,
}).map((doc) => ({prev: doc.previous, next: doc.next, id: doc.id})),
sidebars,
};
}
@ -247,6 +247,7 @@ describe('simple site', () => {
'headingAsTitle.md',
'doc with space.md',
'doc-draft.md',
'doc-unlisted.md',
'customLastUpdate.md',
'lastUpdateAuthorOnly.md',
'lastUpdateDateOnly.md',
@ -256,6 +257,8 @@ describe('simple site', () => {
'slugs/relativeSlug.md',
'slugs/resolvedSlug.md',
'slugs/tryToEscapeSlug.md',
'unlisted-category/index.md',
'unlisted-category/unlisted-category-doc.md',
].sort(),
);
});
@ -279,6 +282,7 @@ describe('simple site', () => {
pagination_prev: null,
},
tags: [],
unlisted: false,
});
await defaultTestUtils.testMeta(path.join('hello.md'), {
version: 'current',
@ -306,6 +310,7 @@ describe('simple site', () => {
permalink: '/docs/tags/tag-3',
},
],
unlisted: false,
});
});
@ -356,6 +361,7 @@ describe('simple site', () => {
permalink: '/docs/tags/tag2-custom-permalink',
},
],
unlisted: false,
});
});
@ -377,6 +383,7 @@ describe('simple site', () => {
unrelated_front_matter: "won't be part of metadata",
},
tags: [],
unlisted: false,
});
});
@ -430,6 +437,7 @@ describe('simple site', () => {
permalink: '/docs/tags/tag2-custom-permalink',
},
],
unlisted: false,
});
expect(editUrlFunction).toHaveBeenCalledTimes(1);
@ -476,6 +484,7 @@ describe('simple site', () => {
formattedLastUpdatedAt: 'Oct 14, 2018',
lastUpdatedBy: 'Author',
tags: [],
unlisted: false,
});
});
@ -501,6 +510,44 @@ describe('simple site', () => {
});
});
it('docs with unlisted frontmatter', async () => {
const {createTestUtilsPartial} = await loadSite();
const baseMeta = {
version: 'current',
id: 'doc-unlisted',
unversionedId: 'doc-unlisted',
sourceDirName: '.',
permalink: '/docs/doc-unlisted',
slug: '/doc-unlisted',
title: 'doc-unlisted',
description: 'This is an unlisted document',
frontMatter: {
unlisted: true,
},
sidebarPosition: undefined,
tags: [],
};
const testUtilsProd = createTestUtilsPartial({
env: 'production',
});
await testUtilsProd.testMeta('doc-unlisted.md', {
...baseMeta,
unlisted: true,
});
const testUtilsDev = createTestUtilsPartial({
env: 'development',
});
await testUtilsDev.testMeta('doc-unlisted.md', {
...baseMeta,
unlisted: false,
});
});
it('docs with last_update front matter', async () => {
const {siteDir, context, options, currentVersion, createTestUtilsPartial} =
await loadSite({
@ -538,6 +585,7 @@ describe('simple site', () => {
lastUpdatedBy: 'Custom Author',
sidebarPosition: undefined,
tags: [],
unlisted: false,
});
});
@ -577,6 +625,7 @@ describe('simple site', () => {
lastUpdatedBy: 'Custom Author',
sidebarPosition: undefined,
tags: [],
unlisted: false,
});
});
@ -616,6 +665,7 @@ describe('simple site', () => {
lastUpdatedBy: 'Author',
sidebarPosition: undefined,
tags: [],
unlisted: false,
});
});
@ -656,6 +706,7 @@ describe('simple site', () => {
lastUpdatedBy: undefined,
sidebarPosition: undefined,
tags: [],
unlisted: false,
});
});
@ -718,12 +769,20 @@ describe('simple site', () => {
);
});
it('custom pagination', async () => {
const {defaultTestUtils, options, versionsMetadata} = await loadSite();
it('custom pagination - production', async () => {
const {createTestUtilsPartial, options, versionsMetadata} =
await loadSite();
const testUtils = createTestUtilsPartial({env: 'production'});
const docs = await readVersionDocs(versionsMetadata[0]!, options);
await expect(
defaultTestUtils.generateNavigation(docs),
).resolves.toMatchSnapshot();
await expect(testUtils.generateNavigation(docs)).resolves.toMatchSnapshot();
});
it('custom pagination - development', async () => {
const {createTestUtilsPartial, options, versionsMetadata} =
await loadSite();
const testUtils = createTestUtilsPartial({env: 'development'});
const docs = await readVersionDocs(versionsMetadata[0]!, options);
await expect(testUtils.generateNavigation(docs)).resolves.toMatchSnapshot();
});
it('bad pagination', async () => {
@ -847,6 +906,7 @@ describe('versioned site', () => {
permalink: '/docs/next/tags/barTag-3-permalink',
},
],
unlisted: false,
});
await currentVersionTestUtils.testMeta(path.join('hello.md'), {
id: 'hello',
@ -861,6 +921,7 @@ describe('versioned site', () => {
slug: '/',
},
tags: [],
unlisted: false,
});
});
@ -878,6 +939,7 @@ describe('versioned site', () => {
frontMatter: {slug: 'barSlug'},
version: '1.0.0',
tags: [],
unlisted: false,
});
await version100TestUtils.testMeta(path.join('hello.md'), {
id: 'version-1.0.0/hello',
@ -894,6 +956,7 @@ describe('versioned site', () => {
source:
'@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md',
tags: [],
unlisted: false,
});
await version101TestUtils.testMeta(path.join('foo', 'bar.md'), {
id: 'version-1.0.1/foo/bar',
@ -906,6 +969,7 @@ describe('versioned site', () => {
version: '1.0.1',
frontMatter: {},
tags: [],
unlisted: false,
});
await version101TestUtils.testMeta(path.join('hello.md'), {
id: 'version-1.0.1/hello',
@ -920,6 +984,7 @@ describe('versioned site', () => {
slug: '/',
},
tags: [],
unlisted: false,
});
});
@ -1016,6 +1081,7 @@ describe('versioned site', () => {
'@site/i18n/en/docusaurus-plugin-content-docs/version-1.0.0/hello.md',
editUrl: hardcodedEditUrl,
tags: [],
unlisted: false,
});
expect(editUrlFunction).toHaveBeenCalledTimes(1);
@ -1059,6 +1125,7 @@ describe('versioned site', () => {
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/versioned_docs/version-1.0.0/hello.md',
tags: [],
unlisted: false,
});
});
@ -1094,6 +1161,7 @@ describe('versioned site', () => {
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/docs/hello.md',
tags: [],
unlisted: false,
});
});
@ -1130,6 +1198,7 @@ describe('versioned site', () => {
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/i18n/fr/docusaurus-plugin-content-docs/version-1.0.0/hello.md',
tags: [],
unlisted: false,
});
});
@ -1167,6 +1236,7 @@ describe('versioned site', () => {
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/i18n/fr/docusaurus-plugin-content-docs/current/hello.md',
tags: [],
unlisted: false,
});
});
});

View file

@ -44,15 +44,12 @@ function testField(params: {
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
try {
validateDocFrontMatter(frontMatter);
// eslint-disable-next-line jest/no-jasmine-globals
fail(
new Error(
`Doc front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
),
throw new Error(
`Doc front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
);
} catch (err) {
// eslint-disable-next-line jest/no-conditional-expect
@ -397,6 +394,41 @@ describe('validateDocFrontMatter draft', () => {
});
});
describe('validateDocFrontMatter unlisted', () => {
testField({
prefix: 'unlisted',
validFrontMatters: [{unlisted: true}, {unlisted: false}],
convertibleFrontMatter: [
[{unlisted: 'true'}, {unlisted: true}],
[{unlisted: 'false'}, {unlisted: false}],
],
invalidFrontMatters: [
[{unlisted: 'yes'}, 'must be a boolean'],
[{unlisted: 'no'}, 'must be a boolean'],
[{unlisted: ''}, 'must be a boolean'],
],
});
});
describe('validateDocFrontMatter draft XOR unlisted', () => {
testField({
prefix: 'draft XOR unlisted',
validFrontMatters: [
{draft: false},
{unlisted: false},
{draft: false, unlisted: false},
{draft: true, unlisted: false},
{draft: false, unlisted: true},
],
invalidFrontMatters: [
[
{draft: true, unlisted: true},
"Can't be draft and unlisted at the same time.",
],
],
});
});
describe('validateDocFrontMatter last_update', () => {
testField({
prefix: 'last_update',

View file

@ -19,12 +19,21 @@ describe('toGlobalDataVersion', () => {
permalink: '/current/main',
sidebar: 'tutorial',
frontMatter: {},
unlisted: false,
},
{
unversionedId: 'doc',
permalink: '/current/doc',
sidebar: 'tutorial',
frontMatter: {},
unlisted: undefined,
},
{
unversionedId: 'docNoSidebarUnlisted',
permalink: '/current/docNoSidebarUnlisted',
sidebar: undefined,
frontMatter: {},
unlisted: true,
},
] as DocMetadata[];
const sidebars: Sidebars = {

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {toTagDocListProp} from '../props';
import {toSidebarDocItemLinkProp, toTagDocListProp} from '../props';
describe('toTagDocListProp', () => {
type Params = Parameters<typeof toTagDocListProp>[0];
@ -61,3 +61,74 @@ describe('toTagDocListProp', () => {
});
});
});
describe('toSidebarDocItemLinkProp', () => {
type Params = Parameters<typeof toSidebarDocItemLinkProp>[0];
type Result = ReturnType<typeof toSidebarDocItemLinkProp>;
type DocSidebarItem = Params['item'];
type Doc = Params['doc'];
const id = 'some-doc-id';
const unversionedId = 'some-unversioned-doc-id';
const item: DocSidebarItem = {
type: 'doc',
id,
label: 'doc sidebar item label',
};
const doc: Doc = {
id,
unversionedId,
title: 'doc title',
permalink: '/docPermalink',
frontMatter: {},
unlisted: false,
};
it('works', () => {
const result = toSidebarDocItemLinkProp({
item,
doc,
});
expect(result).toEqual({
type: 'link',
docId: unversionedId,
unlisted: false,
label: item.label,
autoAddBaseUrl: undefined,
className: undefined,
href: doc.permalink,
customProps: undefined,
} as Result);
});
it('uses unlisted from metadata and ignores frontMatter', () => {
expect(
toSidebarDocItemLinkProp({
item,
doc: {
...doc,
unlisted: true,
frontMatter: {
unlisted: false,
},
},
}).unlisted,
).toBe(true);
expect(
toSidebarDocItemLinkProp({
item,
doc: {
...doc,
unlisted: false,
frontMatter: {
unlisted: true,
},
},
}).unlisted,
).toBe(false);
});
});

View file

@ -37,7 +37,8 @@ export type GlobalDoc = {
*/
id: string;
path: string;
sidebar: string | undefined;
sidebar?: string;
unlisted?: boolean;
};
export type GlobalVersion = {

View file

@ -18,6 +18,8 @@ import {
posixPath,
Globby,
normalizeFrontMatterTags,
isUnlisted,
isDraft,
} from '@docusaurus/utils';
import {getFileLastUpdate} from './lastUpdate';
@ -35,7 +37,6 @@ import type {
PropNavigationLink,
LastUpdateData,
VersionMetadata,
DocFrontMatter,
LoadedVersion,
FileChange,
} from '@docusaurus/plugin-content-docs';
@ -125,17 +126,6 @@ export async function readVersionDocs(
export type DocEnv = 'production' | 'development';
/** Docs with draft front matter are only considered draft in production. */
function isDraftForEnvironment({
env,
frontMatter,
}: {
frontMatter: DocFrontMatter;
env: DocEnv;
}): boolean {
return (env === 'production' && frontMatter.draft) ?? false;
}
async function doProcessDocMetadata({
docFile,
versionMetadata,
@ -268,7 +258,8 @@ async function doProcessDocMetadata({
return undefined;
}
const draft = isDraftForEnvironment({env, frontMatter});
const draft = isDraft({env, frontMatter});
const unlisted = isUnlisted({env, frontMatter});
const formatDate = (locale: string, date: Date, calendar: string): string => {
try {
@ -299,6 +290,7 @@ async function doProcessDocMetadata({
slug: docSlug,
permalink,
draft,
unlisted,
editUrl: customEditURL !== undefined ? customEditURL : getDocEditUrl(),
tags: normalizeFrontMatterTags(versionMetadata.tagsPath, frontMatter.tags),
version: versionMetadata.versionName,
@ -333,25 +325,32 @@ export async function processDocMetadata(args: {
}
}
export function addDocNavigation(
docsBase: DocMetadataBase[],
sidebarsUtils: SidebarsUtils,
sidebarFilePath: string,
): LoadedVersion['docs'] {
const docsById = createDocsByIdIndex(docsBase);
function getUnlistedIds(docs: DocMetadataBase[]): Set<string> {
return new Set(docs.filter((doc) => doc.unlisted).map((doc) => doc.id));
}
sidebarsUtils.checkSidebarsDocIds(
docsBase.flatMap(getDocIds),
sidebarFilePath,
);
export function addDocNavigation({
docs,
sidebarsUtils,
sidebarFilePath,
}: {
docs: DocMetadataBase[];
sidebarsUtils: SidebarsUtils;
sidebarFilePath: string;
}): LoadedVersion['docs'] {
const docsById = createDocsByIdIndex(docs);
const unlistedIds = getUnlistedIds(docs);
sidebarsUtils.checkSidebarsDocIds(docs.flatMap(getDocIds), sidebarFilePath);
// Add sidebar/next/previous to the docs
function addNavData(doc: DocMetadataBase): DocMetadata {
const navigation = sidebarsUtils.getDocNavigation(
doc.unversionedId,
doc.id,
doc.frontMatter.displayed_sidebar,
);
const navigation = sidebarsUtils.getDocNavigation({
unversionedId: doc.unversionedId,
versionedId: doc.id,
displayedSidebar: doc.frontMatter.displayed_sidebar,
unlistedIds,
});
const toNavigationLinkByDocId = (
docId: string | null | undefined,
@ -367,6 +366,10 @@ export function addDocNavigation(
`Error when loading ${doc.id} in ${doc.sourceDirName}: the pagination_${type} front matter points to a non-existent ID ${docId}.`,
);
}
// Gracefully handle explicitly providing an unlisted doc ID in production
if (navDoc.unlisted) {
return undefined;
}
return toDocNavigationLink(navDoc);
};
@ -382,7 +385,7 @@ export function addDocNavigation(
return {...doc, sidebar: navigation.sidebarName, previous, next};
}
const docsWithNavigation = docsBase.map(addNavData);
const docsWithNavigation = docs.map(addNavData);
// Sort to ensure consistent output for tests
docsWithNavigation.sort((a, b) => a.id.localeCompare(b.id));
return docsWithNavigation;

View file

@ -11,6 +11,7 @@ import {
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
validateFrontMatter,
ContentVisibilitySchema,
} from '@docusaurus/utils-validation';
import type {DocFrontMatter} from '@docusaurus/plugin-content-docs';
@ -43,7 +44,6 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
parse_number_prefixes: Joi.boolean(),
pagination_next: Joi.string().allow(null),
pagination_prev: Joi.string().allow(null),
draft: Joi.boolean(),
...FrontMatterTOCHeadingLevels,
last_update: Joi.object({
author: Joi.string(),
@ -54,7 +54,9 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
'object.missing': FrontMatterLastUpdateErrorMessage,
'object.base': FrontMatterLastUpdateErrorMessage,
}),
}).unknown();
})
.unknown()
.concat(ContentVisibilitySchema);
export function validateDocFrontMatter(frontMatter: {
[key: string]: unknown;

View file

@ -23,6 +23,11 @@ function toGlobalDataDoc(doc: DocMetadata): GlobalDoc {
return {
id: doc.unversionedId,
path: doc.permalink,
// optimize global data size: do not add unlisted: false/undefined
...(doc.unlisted && {unlisted: doc.unlisted}),
// TODO optimize size? remove attribute when no sidebar (breaking change?)
sidebar: doc.sidebar,
};
}

View file

@ -75,6 +75,9 @@ export default async function pluginContentDocs(
const aliasedSource = (source: string) =>
`~docs/${posixPath(path.relative(pluginDataDirRoot, source))}`;
// TODO env should be injected into all plugins
const env = process.env.NODE_ENV as DocEnv;
return {
name: 'docusaurus-plugin-content-docs',
@ -143,7 +146,7 @@ export default async function pluginContentDocs(
versionMetadata,
context,
options,
env: process.env.NODE_ENV as DocEnv,
env,
});
}
return Promise.all(docFiles.map(processVersionDoc));
@ -156,6 +159,9 @@ export default async function pluginContentDocs(
versionMetadata,
);
// TODO we only ever need draftIds in further code, not full draft items
// To simplify and prevent mistakes, avoid exposing draft
// replace draft=>draftIds in content loaded
const [drafts, docs] = _.partition(docsBase, (doc) => doc.draft);
const sidebars = await loadSidebars(versionMetadata.sidebarFilePath, {
@ -175,11 +181,11 @@ export default async function pluginContentDocs(
return {
...versionMetadata,
docs: addDocNavigation(
docs: addDocNavigation({
docs,
sidebarsUtils,
versionMetadata.sidebarFilePath as string,
),
sidebarFilePath: versionMetadata.sidebarFilePath as string,
}),
drafts,
sidebars,
};

View file

@ -398,6 +398,8 @@ declare module '@docusaurus/plugin-content-docs' {
pagination_prev?: string | null;
/** Should this doc be excluded from production builds? */
draft?: boolean;
/** Should this doc be accessible but hidden in production builds? */
unlisted?: boolean;
/** Allows overriding the last updated author and/or date. */
last_update?: FileChange;
};
@ -448,6 +450,11 @@ declare module '@docusaurus/plugin-content-docs' {
* Draft docs will be excluded for production environment.
*/
draft: boolean;
/**
* Unlisted docs are accessible when directly visible, but will be hidden
* from the sidebar and pagination in production.
*/
unlisted: boolean;
/**
* Position in an autogenerated sidebar slice, acquired through front matter
* or number prefix.

View file

@ -28,6 +28,37 @@ import type {
LoadedVersion,
} from '@docusaurus/plugin-content-docs';
export function toSidebarDocItemLinkProp({
item,
doc,
}: {
item: SidebarItemDoc;
doc: Pick<
DocMetadata,
'id' | 'title' | 'permalink' | 'unlisted' | 'frontMatter' | 'unversionedId'
>;
}): PropSidebarItemLink {
const {
title,
permalink,
frontMatter: {
sidebar_label: sidebarLabel,
sidebar_custom_props: customProps,
},
unlisted,
unversionedId,
} = doc;
return {
type: 'link',
label: sidebarLabel ?? item.label ?? title,
href: permalink,
className: item.className,
customProps: item.customProps ?? customProps,
docId: unversionedId,
unlisted,
};
}
export function toSidebarsProp(loadedVersion: LoadedVersion): PropSidebars {
const docsById = createDocsByIdIndex(loadedVersion.docs);
@ -44,21 +75,8 @@ Available document ids are:
}
const convertDocLink = (item: SidebarItemDoc): PropSidebarItemLink => {
const docMetadata = getDocById(item.id);
const {
title,
permalink,
frontMatter: {sidebar_label: sidebarLabel},
} = docMetadata;
return {
type: 'link',
label: sidebarLabel ?? item.label ?? title,
href: permalink,
className: item.className,
customProps:
item.customProps ?? docMetadata.frontMatter.sidebar_custom_props,
docId: docMetadata.unversionedId,
};
const doc = getDocById(item.id);
return toSidebarDocItemLinkProp({item, doc});
};
function getCategoryLinkHref(
@ -74,6 +92,15 @@ Available document ids are:
}
}
function getCategoryLinkUnlisted(
link: SidebarItemCategoryLink | undefined,
): boolean {
if (link?.type === 'doc') {
return getDocById(link.id).unlisted;
}
return false;
}
function getCategoryLinkCustomProps(
link: SidebarItemCategoryLink | undefined,
) {
@ -88,12 +115,14 @@ Available document ids are:
function convertCategory(item: SidebarItemCategory): PropSidebarItemCategory {
const {link, ...rest} = item;
const href = getCategoryLinkHref(link);
const linkUnlisted = getCategoryLinkUnlisted(link);
const customProps = item.customProps ?? getCategoryLinkCustomProps(link);
return {
...rest,
items: item.items.map(normalizeItem),
...(href && {href}),
...(linkUnlisted && {linkUnlisted}),
...(customProps && {customProps}),
};
}
@ -180,15 +209,18 @@ export function toTagDocListProp({
allTagsPath,
count: tag.docIds.length,
items: toDocListProp(),
unlisted: tag.unlisted,
};
}
export function toTagsListTagsProp(
versionTags: VersionTags,
): PropTagsListPage['tags'] {
return Object.values(versionTags).map((tagValue) => ({
label: tagValue.label,
permalink: tagValue.permalink,
count: tagValue.docIds.length,
}));
return Object.values(versionTags)
.filter((tagValue) => !tagValue.unlisted)
.map((tagValue) => ({
label: tagValue.label,
permalink: tagValue.permalink,
count: tagValue.docIds.length,
}));
}

View file

@ -153,7 +153,14 @@ describe('createSidebarsUtils', () => {
});
it('getDocNavigation', () => {
expect(getDocNavigation('doc1', 'doc1', undefined)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc1',
versionedId: 'doc1',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar1',
previous: undefined,
next: {
@ -161,7 +168,14 @@ describe('createSidebarsUtils', () => {
id: 'doc2',
},
});
expect(getDocNavigation('doc2', 'doc2', undefined)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc2',
versionedId: 'doc2',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar1',
previous: {
type: 'doc',
@ -170,7 +184,14 @@ describe('createSidebarsUtils', () => {
next: undefined,
});
expect(getDocNavigation('doc3', 'doc3', undefined)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc3',
versionedId: 'doc3',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar2',
previous: undefined,
next: {
@ -178,7 +199,14 @@ describe('createSidebarsUtils', () => {
id: 'doc4',
},
});
expect(getDocNavigation('doc4', 'doc4', undefined)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc4',
versionedId: 'doc4',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar2',
previous: {
type: 'doc',
@ -188,7 +216,14 @@ describe('createSidebarsUtils', () => {
next: undefined,
});
expect(getDocNavigation('doc5', 'doc5', undefined)).toMatchObject({
expect(
getDocNavigation({
unversionedId: 'doc5',
versionedId: 'doc5',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toMatchObject({
sidebarName: 'sidebar3',
previous: undefined,
next: {
@ -196,7 +231,14 @@ describe('createSidebarsUtils', () => {
label: 'S3 SubCategory',
},
});
expect(getDocNavigation('doc6', 'doc6', undefined)).toMatchObject({
expect(
getDocNavigation({
unversionedId: 'doc6',
versionedId: 'doc6',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toMatchObject({
sidebarName: 'sidebar3',
previous: {
type: 'category',
@ -207,7 +249,14 @@ describe('createSidebarsUtils', () => {
id: 'doc7',
},
});
expect(getDocNavigation('doc7', 'doc7', undefined)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc7',
versionedId: 'doc7',
displayedSidebar: undefined,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar3',
previous: {
type: 'doc',
@ -215,17 +264,36 @@ describe('createSidebarsUtils', () => {
},
next: undefined,
});
expect(getDocNavigation('doc3', 'doc3', null)).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc3',
versionedId: 'doc3',
displayedSidebar: null,
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: undefined,
previous: undefined,
next: undefined,
});
expect(() =>
getDocNavigation('doc3', 'doc3', 'foo'),
getDocNavigation({
unversionedId: 'doc3',
versionedId: 'doc3',
displayedSidebar: 'foo',
unlistedIds: new Set(),
}),
).toThrowErrorMatchingInlineSnapshot(
`"Doc with ID doc3 wants to display sidebar foo but a sidebar with this name doesn't exist"`,
);
expect(getDocNavigation('doc3', 'doc3', 'sidebar1')).toEqual({
expect(
getDocNavigation({
unversionedId: 'doc3',
versionedId: 'doc3',
displayedSidebar: 'sidebar1',
unlistedIds: new Set(),
}),
).toEqual({
sidebarName: 'sidebar1',
previous: undefined,
next: undefined,

View file

@ -183,11 +183,18 @@ export type PropSidebarItemCategory = Expand<
SidebarItemCategoryBase & {
items: PropSidebarItem[];
href?: string;
// Weird name => it would have been more convenient to have link.unlisted
// Note it is the category link that is unlisted, not the category itself
// We want to prevent users from clicking on an unlisted category link
// We can't use "href: undefined" otherwise sidebar item is not highlighted
linkUnlisted?: boolean;
}
>;
export type PropSidebarItemLink = SidebarItemLink & {
docId?: string;
unlisted?: boolean;
};
export type PropSidebarItemHtml = SidebarItemHtml;

View file

@ -135,11 +135,12 @@ export type SidebarsUtils = {
sidebars: Sidebars;
getFirstDocIdOfFirstSidebar: () => string | undefined;
getSidebarNameByDocId: (docId: string) => string | undefined;
getDocNavigation: (
unversionedId: string,
versionedId: string,
displayedSidebar: string | null | undefined,
) => SidebarNavigation;
getDocNavigation: (params: {
unversionedId: string;
versionedId: string;
displayedSidebar: string | null | undefined;
unlistedIds: Set<string>;
}) => SidebarNavigation;
getCategoryGeneratedIndexList: () => SidebarItemCategoryWithGeneratedIndex[];
getCategoryGeneratedIndexNavigation: (
categoryGeneratedIndexPermalink: string,
@ -192,11 +193,17 @@ export function createSidebarsUtils(sidebars: Sidebars): SidebarsUtils {
};
}
function getDocNavigation(
unversionedId: string,
versionedId: string,
displayedSidebar: string | null | undefined,
): SidebarNavigation {
function getDocNavigation({
unversionedId,
versionedId,
displayedSidebar,
unlistedIds,
}: {
unversionedId: string;
versionedId: string;
displayedSidebar: string | null | undefined;
unlistedIds: Set<string>;
}): SidebarNavigation {
// TODO legacy id retro-compatibility!
let docId = unversionedId;
let sidebarName =
@ -211,12 +218,28 @@ export function createSidebarsUtils(sidebars: Sidebars): SidebarsUtils {
if (!sidebarName) {
return emptySidebarNavigation();
}
const navigationItems = sidebarNameToNavigationItems[sidebarName];
let navigationItems = sidebarNameToNavigationItems[sidebarName];
if (!navigationItems) {
throw new Error(
`Doc with ID ${docId} wants to display sidebar ${sidebarName} but a sidebar with this name doesn't exist`,
);
}
// Filter unlisted items from navigation
navigationItems = navigationItems.filter((item) => {
if (item.type === 'doc' && unlistedIds.has(item.id)) {
return false;
}
if (
item.type === 'category' &&
item.link.type === 'doc' &&
unlistedIds.has(item.link.id)
) {
return false;
}
return true;
});
const currentItemIndex = navigationItems.findIndex((item) => {
if (item.type === 'doc') {
return item.id === docId;

View file

@ -6,15 +6,22 @@
*/
import _ from 'lodash';
import {groupTaggedItems} from '@docusaurus/utils';
import {getTagVisibility, groupTaggedItems} from '@docusaurus/utils';
import type {VersionTags} from './types';
import type {DocMetadata} from '@docusaurus/plugin-content-docs';
export function getVersionTags(docs: DocMetadata[]): VersionTags {
const groups = groupTaggedItems(docs, (doc) => doc.tags);
return _.mapValues(groups, (group) => ({
label: group.tag.label,
docIds: group.items.map((item) => item.id),
permalink: group.tag.permalink,
}));
return _.mapValues(groups, ({tag, items: tagDocs}) => {
const tagVisibility = getTagVisibility({
items: tagDocs,
isUnlisted: (item) => item.unlisted,
});
return {
label: tag.label,
docIds: tagVisibility.listedItems.map((item) => item.id),
permalink: tag.permalink,
unlisted: tagVisibility.unlisted,
};
});
}

View file

@ -27,6 +27,7 @@ export type SourceToPermalink = {
export type VersionTag = Tag & {
/** All doc ids having this tag. */
docIds: string[];
unlisted: boolean;
};
export type VersionTags = {
[permalink: string]: VersionTag;

View file

@ -19,6 +19,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages 1`] = `
"source": "@site/src/pages/hello/index.md",
"title": "Index",
"type": "mdx",
"unlisted": false,
},
{
"description": "my MDX page",
@ -30,6 +31,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages 1`] = `
"source": "@site/src/pages/hello/mdxPage.mdx",
"title": "MDX page",
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/hello/translatedJs",
@ -43,6 +45,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages 1`] = `
"source": "@site/src/pages/hello/translatedMd.md",
"title": undefined,
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/hello/world",
@ -71,6 +74,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages with french translat
"source": "@site/src/pages/hello/index.md",
"title": "Index",
"type": "mdx",
"unlisted": false,
},
{
"description": "my MDX page",
@ -82,6 +86,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages with french translat
"source": "@site/src/pages/hello/mdxPage.mdx",
"title": "MDX page",
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/fr/hello/translatedJs",
@ -95,6 +100,7 @@ exports[`docusaurus-plugin-content-pages loads simple pages with french translat
"source": "@site/i18n/fr/docusaurus-plugin-content-pages/hello/translatedMd.md",
"title": undefined,
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/fr/hello/world",

View file

@ -9,6 +9,7 @@ import {
Joi,
validateFrontMatter,
FrontMatterTOCHeadingLevels,
ContentVisibilitySchema,
} from '@docusaurus/utils-validation';
import type {FrontMatter} from '@docusaurus/plugin-content-pages';
@ -18,7 +19,7 @@ const PageFrontMatterSchema = Joi.object<FrontMatter>({
wrapperClassName: Joi.string(),
hide_table_of_contents: Joi.boolean(),
...FrontMatterTOCHeadingLevels,
});
}).concat(ContentVisibilitySchema);
export function validatePageFrontMatter(frontMatter: {
[key: string]: unknown;

View file

@ -20,6 +20,8 @@ import {
normalizeUrl,
DEFAULT_PLUGIN_ID,
parseMarkdownString,
isUnlisted,
isDraft,
} from '@docusaurus/utils';
import {validatePageFrontMatter} from './frontMatter';
@ -82,7 +84,9 @@ export default function pluginContentPages(
ignore: options.exclude,
});
async function toMetadata(relativeSource: string): Promise<Metadata> {
async function processPageSourceFile(
relativeSource: string,
): Promise<Metadata | undefined> {
// Lookup in localized folder in priority
const contentPath = await getFolderContainingFile(
getContentPathList(contentPaths),
@ -110,6 +114,12 @@ export default function pluginContentPages(
excerpt,
} = parseMarkdownString(content);
const frontMatter = validatePageFrontMatter(unsafeFrontMatter);
if (isDraft({frontMatter})) {
return undefined;
}
const unlisted = isUnlisted({frontMatter});
return {
type: 'mdx',
permalink,
@ -117,10 +127,24 @@ export default function pluginContentPages(
title: frontMatter.title ?? contentTitle,
description: frontMatter.description ?? excerpt,
frontMatter,
unlisted,
};
}
return Promise.all(pagesFiles.map(toMetadata));
async function doProcessPageSourceFile(relativeSource: string) {
try {
return await processPageSourceFile(relativeSource);
} catch (err) {
throw new Error(
`Processing of page source file path=${relativeSource} failed.`,
{cause: err as Error},
);
}
}
return (
await Promise.all(pagesFiles.map(doProcessPageSourceFile))
).filter(Boolean) as Metadata[];
},
async contentLoaded({content, actions}) {

View file

@ -27,6 +27,8 @@ declare module '@docusaurus/plugin-content-pages' {
readonly hide_table_of_contents?: string;
readonly toc_min_heading_level?: number;
readonly toc_max_heading_level?: number;
readonly draft?: boolean;
readonly unlisted?: boolean;
};
export type JSXPageMetadata = {
@ -42,6 +44,7 @@ declare module '@docusaurus/plugin-content-pages' {
frontMatter: FrontMatter & {[key: string]: unknown};
title?: string;
description?: string;
unlisted: boolean;
};
export type Metadata = JSXPageMetadata | MDXPageMetadata;

View file

@ -1494,6 +1494,14 @@ declare module '@theme/Tag' {
export default function Tag(props: Props): JSX.Element;
}
declare module '@theme/Unlisted' {
export interface Props {
className?: string;
}
export default function Unlisted(props: Props): JSX.Element;
}
declare module '@theme/prism-include-languages' {
import type * as PrismNamespace from 'prismjs';

View file

@ -15,6 +15,7 @@ import BlogPostPaginator from '@theme/BlogPostPaginator';
import BlogPostPageMetadata from '@theme/BlogPostPage/Metadata';
import TOC from '@theme/TOC';
import type {Props} from '@theme/BlogPostPage';
import Unlisted from '@theme/Unlisted';
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
function BlogPostPageContent({
@ -25,7 +26,7 @@ function BlogPostPageContent({
children: ReactNode;
}): JSX.Element {
const {metadata, toc} = useBlogPost();
const {nextItem, prevItem, frontMatter} = metadata;
const {nextItem, prevItem, frontMatter, unlisted} = metadata;
const {
hide_table_of_contents: hideTableOfContents,
toc_min_heading_level: tocMinHeadingLevel,
@ -43,6 +44,7 @@ function BlogPostPageContent({
/>
) : undefined
}>
{unlisted && <Unlisted />}
<BlogPostItem>{children}</BlogPostItem>
{(nextItem || prevItem) && (

View file

@ -9,11 +9,13 @@ import React from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import {translate} from '@docusaurus/Translate';
import {useVisibleBlogSidebarItems} from '@docusaurus/theme-common/internal';
import type {Props} from '@theme/BlogSidebar/Desktop';
import styles from './styles.module.css';
export default function BlogSidebarDesktop({sidebar}: Props): JSX.Element {
const items = useVisibleBlogSidebarItems(sidebar.items);
return (
<aside className="col col--3">
<nav
@ -27,7 +29,7 @@ export default function BlogSidebarDesktop({sidebar}: Props): JSX.Element {
{sidebar.title}
</div>
<ul className={clsx(styles.sidebarItemList, 'clean-list')}>
{sidebar.items.map((item) => (
{items.map((item) => (
<li key={item.permalink} className={styles.sidebarItem}>
<Link
isNavLink

View file

@ -7,13 +7,15 @@
import React from 'react';
import Link from '@docusaurus/Link';
import {useVisibleBlogSidebarItems} from '@docusaurus/theme-common/internal';
import {NavbarSecondaryMenuFiller} from '@docusaurus/theme-common';
import type {Props} from '@theme/BlogSidebar/Mobile';
function BlogSidebarMobileSecondaryMenu({sidebar}: Props): JSX.Element {
const items = useVisibleBlogSidebarItems(sidebar.items);
return (
<ul className="menu__list">
{sidebar.items.map((item) => (
{items.map((item) => (
<li key={item.permalink} className="menu__list-item">
<Link
isNavLink

View file

@ -20,6 +20,7 @@ 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';
// Very simple pluralization: probably good enough for now
function useBlogPostsPlural() {
@ -70,9 +71,9 @@ function BlogTagsPostsPageContent({
const title = useBlogTagsPostsPageTitle(tag);
return (
<BlogLayout sidebar={sidebar}>
{tag.unlisted && <Unlisted />}
<header className="margin-bottom--xl">
<h1>{title}</h1>
<Link href={tag.allTagsPath}>
<Translate
id="theme.tags.tagsPageLink"

View file

@ -123,13 +123,17 @@ export default function DocBreadcrumbs(): JSX.Element | null {
{homePageRoute && <HomeBreadcrumbItem />}
{breadcrumbs.map((item, idx) => {
const isLast = idx === breadcrumbs.length - 1;
const href =
item.type === 'category' && item.linkUnlisted
? undefined
: item.href;
return (
<BreadcrumbsItem
key={idx}
active={isLast}
index={idx}
addMicrodata={!!item.href}>
<BreadcrumbsItemLink href={item.href} isLast={isLast}>
addMicrodata={!!href}>
<BreadcrumbsItemLink href={href} isLast={isLast}>
{item.label}
</BreadcrumbsItemLink>
</BreadcrumbsItem>

View file

@ -17,6 +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 type {Props} from '@theme/DocItem/Layout';
import styles from './styles.module.css';
@ -47,9 +48,13 @@ function useDocTOC() {
export default function DocItemLayout({children}: Props): JSX.Element {
const docTOC = useDocTOC();
const {
metadata: {unlisted},
} = useDoc();
return (
<div className="row">
<div className={clsx('col', !docTOC.hidden && styles.docItemCol)}>
{unlisted && <Unlisted />}
<DocVersionBanner />
<div className={styles.docItemContainer}>
<article>

View file

@ -59,7 +59,7 @@ function useCategoryHrefWithSSRFallback(
): string | undefined {
const isBrowser = useIsBrowser();
return useMemo(() => {
if (item.href) {
if (item.href && !item.linkUnlisted) {
return item.href;
}
// In these cases, it's not necessary to render a fallback

View file

@ -6,17 +6,19 @@
*/
import React, {memo} from 'react';
import {DocSidebarItemsExpandedStateProvider} from '@docusaurus/theme-common/internal';
import {
DocSidebarItemsExpandedStateProvider,
useVisibleSidebarItems,
} from '@docusaurus/theme-common/internal';
import DocSidebarItem from '@theme/DocSidebarItem';
import type {Props} from '@theme/DocSidebarItems';
// TODO this item should probably not receive the "activePath" props
// TODO this triggers whole sidebar re-renders on navigation
function DocSidebarItems({items, ...props}: Props): JSX.Element {
const visibleItems = useVisibleSidebarItems(items, props.activePath);
return (
<DocSidebarItemsExpandedStateProvider>
{items.map((item, index) => (
{visibleItems.map((item, index) => (
<DocSidebarItem key={index} item={item} index={index} {...props} />
))}
</DocSidebarItemsExpandedStateProvider>

View file

@ -17,6 +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';
// Very simple pluralization: probably good enough for now
function useNDocsTaggedPlural() {
@ -80,6 +81,7 @@ function DocTagDocListPageContent({
<div className="container margin-vert--lg">
<div className="row">
<main className="col col--8 col--offset-2">
{tag.unlisted && <Unlisted />}
<header className="margin-bottom--xl">
<h1>{title}</h1>
<Link href={tag.allTagsPath}>

View file

@ -15,6 +15,7 @@ import {
import Layout from '@theme/Layout';
import MDXContent from '@theme/MDXContent';
import TOC from '@theme/TOC';
import Unlisted from '@theme/Unlisted';
import type {Props} from '@theme/MDXPage';
import styles from './styles.module.css';
@ -22,7 +23,7 @@ import styles from './styles.module.css';
export default function MDXPage(props: Props): JSX.Element {
const {content: MDXPageContent} = props;
const {
metadata: {title, description, frontMatter},
metadata: {title, description, frontMatter, unlisted},
} = MDXPageContent;
const {wrapperClassName, hide_table_of_contents: hideTableOfContents} =
frontMatter;
@ -38,6 +39,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 />}
<article>
<MDXContent>
<MDXPageContent />

View file

@ -19,9 +19,10 @@ export default function DocNavbarItem({
}: Props): JSX.Element | null {
const {activeDoc} = useActiveDocContext(docsPluginId);
const doc = useLayoutDoc(docId, docsPluginId);
const pageActive = activeDoc?.path === doc?.path;
// Draft items are not displayed in the navbar.
if (doc === null) {
// Draft and unlisted items are not displayed in the navbar.
if (doc === null || (doc.unlisted && !pageActive)) {
return null;
}
@ -30,7 +31,7 @@ export default function DocNavbarItem({
exact
{...props}
isActive={() =>
activeDoc?.path === doc.path ||
pageActive ||
(!!activeDoc?.sidebar && activeDoc.sidebar === doc.sidebar)
}
label={staticLabel ?? doc.id}

View file

@ -0,0 +1,42 @@
/**
* 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,
UnlistedBannerTitle,
UnlistedBannerMessage,
UnlistedMetadata,
} from '@docusaurus/theme-common';
import Admonition from '@theme/Admonition';
import type {Props} from '@theme/Unlisted';
function UnlistedBanner({className}: Props) {
return (
<Admonition
type="caution"
title={<UnlistedBannerTitle />}
className={clsx(className, ThemeClassNames.common.unlistedBanner)}>
<UnlistedBannerMessage />
</Admonition>
);
}
export default function Unlisted(props: Props): JSX.Element | null {
return (
<>
{/*
Unlisted metadata declared here for simplicity.
Ensures we never forget to add the correct noindex metadata.
Also gives a central place for user to swizzle override default metadata.
*/}
<UnlistedMetadata />
<UnlistedBanner {...props} />
</>
);
}

View file

@ -86,4 +86,10 @@ export {
SkipToContentLink,
} from './utils/skipToContentUtils';
export {
UnlistedBannerTitle,
UnlistedBannerMessage,
UnlistedMetadata,
} from './utils/unlistedUtils';
export {ErrorBoundaryTryAgainButton} from './utils/errorBoundaryUtils';

View file

@ -69,6 +69,8 @@ export {
findSidebarCategory,
findFirstCategoryLink,
isActiveSidebarItem,
isVisibleSidebarItem,
useVisibleSidebarItems,
useSidebarBreadcrumbs,
useDocsVersionCandidates,
useLayoutDoc,
@ -109,6 +111,8 @@ export {
type TOCHighlightConfig,
} from './hooks/useTOCHighlight';
export {useVisibleBlogSidebarItems} from './utils/blogUtils';
export {useHideableNavbar} from './hooks/useHideableNavbar';
export {
useKeyboardNavigation,

View file

@ -40,6 +40,8 @@ export const ThemeClassNames = {
backToTopButton: 'theme-back-to-top-button',
codeBlock: 'theme-code-block',
admonition: 'theme-admonition',
unlistedBanner: 'theme-unlisted-banner',
admonitionType: (type: string) => `theme-admonition-${type}`,
},
layout: {

View file

@ -16,6 +16,7 @@ import {
findSidebarCategory,
useCurrentSidebarCategory,
useSidebarBreadcrumbs,
isVisibleSidebarItem,
} from '../docsUtils';
import {DocsSidebarProvider} from '../../contexts/docsSidebar';
import {DocsVersionProvider} from '../../contexts/docsVersion';
@ -293,6 +294,98 @@ describe('isActiveSidebarItem', () => {
});
});
describe('isVisibleSidebarItem', () => {
it('works with item', () => {
const item: PropSidebarItem = {
type: 'link',
href: '/itemPath',
label: 'Label',
};
expect(isVisibleSidebarItem(item, item.href)).toBe(true);
expect(isVisibleSidebarItem(item, '/nonexistentPath/')).toBe(true);
expect(isVisibleSidebarItem({...item, unlisted: false}, item.href)).toBe(
true,
);
expect(
isVisibleSidebarItem({...item, unlisted: undefined}, item.href),
).toBe(true);
expect(isVisibleSidebarItem({...item, unlisted: true}, item.href)).toBe(
true,
);
expect(
isVisibleSidebarItem({...item, unlisted: true}, '/nonexistentPath/'),
).toBe(false);
});
it('works with category', () => {
const subCategoryAllUnlisted = testCategory({
href: '/sub-category-path',
items: [
{
type: 'link',
href: '/sub-sub-link-path',
label: 'Label',
unlisted: true,
},
{
type: 'link',
href: '/sub-sub-link-path',
label: 'Label',
unlisted: true,
},
testCategory({
href: '/sub-sub-category-path',
items: [
{
type: 'link',
href: '/sub-sub-sub-link-path',
label: 'Label',
unlisted: true,
},
],
}),
],
});
expect(
isVisibleSidebarItem(subCategoryAllUnlisted, '/nonexistentPath'),
).toBe(false);
expect(
isVisibleSidebarItem(
subCategoryAllUnlisted,
subCategoryAllUnlisted.href!,
),
).toBe(true);
expect(
isVisibleSidebarItem(subCategoryAllUnlisted, '/sub-sub-link-path'),
).toBe(true);
expect(
isVisibleSidebarItem(subCategoryAllUnlisted, '/sub-sub-sub-link-path'),
).toBe(true);
const categorySomeUnlisted = testCategory({
href: '/category-path',
items: [
{
type: 'link',
href: '/sub-link-path',
label: 'Label',
},
subCategoryAllUnlisted,
],
});
expect(isVisibleSidebarItem(categorySomeUnlisted, '/nonexistentPath')).toBe(
true,
);
expect(
isVisibleSidebarItem(categorySomeUnlisted, categorySomeUnlisted.href!),
).toBe(true);
});
});
describe('useSidebarBreadcrumbs', () => {
const createUseSidebarBreadcrumbsMock =
(sidebar: PropSidebar | undefined, breadcrumbsOption?: boolean) =>

View file

@ -0,0 +1,32 @@
/**
* 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 {useMemo} from 'react';
import {useLocation} from '@docusaurus/router';
import {isSamePath} from './routesUtils';
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
function isVisible(item: BlogSidebarItem, pathname: string): boolean {
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
return false;
}
return true;
}
/**
* Return the visible blog sidebar items to display.
* Unlisted items are filtered.
*/
export function useVisibleBlogSidebarItems(
items: BlogSidebarItem[],
): BlogSidebarItem[] {
const {pathname} = useLocation();
return useMemo(
() => items.filter((item) => isVisible(item, pathname)),
[items, pathname],
);
}

View file

@ -152,6 +152,34 @@ export function isActiveSidebarItem(
return false;
}
export function isVisibleSidebarItem(
item: PropSidebarItem,
activePath: string,
): boolean {
switch (item.type) {
case 'category':
return (
isActiveSidebarItem(item, activePath) ||
item.items.some((subItem) => isVisibleSidebarItem(subItem, activePath))
);
case 'link':
// An unlisted item remains visible if it is active
return !item.unlisted || isActiveSidebarItem(item, activePath);
default:
return false;
}
}
export function useVisibleSidebarItems(
items: readonly PropSidebarItem[],
activePath: string,
): PropSidebarItem[] {
return useMemo(
() => items.filter((item) => isVisibleSidebarItem(item, activePath)),
[items, activePath],
);
}
function getSidebarBreadcrumbs(param: {
sidebarItems: PropSidebar;
pathname: string;

View file

@ -0,0 +1,39 @@
/**
* 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 Translate from '@docusaurus/Translate';
import Head from '@docusaurus/Head';
export function UnlistedBannerTitle(): JSX.Element {
return (
<Translate
id="theme.unlistedContent.title"
description="The unlisted content banner title">
Unlisted page
</Translate>
);
}
export function UnlistedBannerMessage(): JSX.Element {
return (
<Translate
id="theme.unlistedContent.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.
</Translate>
);
}
export function UnlistedMetadata(): JSX.Element {
return (
<Head>
<meta name="robots" content="noindex, nofollow" />
</Head>
);
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "إصدارات",
"theme.tags.tagsListLabel": "الوسوم:",
"theme.tags.tagsPageLink": "عرض كل الوسوم",
"theme.tags.tagsPageTitle": "الوسوم"
"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"
}

View file

@ -16,7 +16,7 @@
"theme.ErrorPageContent.title": "This page crashed.",
"theme.ErrorPageContent.title___DESCRIPTION": "The title of the fallback page when the page crashed",
"theme.ErrorPageContent.tryAgain": "Try again",
"theme.ErrorPageContent.tryAgain___DESCRIPTION": "The label of the button to try again when the page crashed",
"theme.ErrorPageContent.tryAgain___DESCRIPTION": "The label of the button to try again rendering when the React error boundary captures an error",
"theme.NotFound.p1": "We could not find what you were looking for.",
"theme.NotFound.p1___DESCRIPTION": "The first paragraph of the 404 page",
"theme.NotFound.p2": "Please contact the owner of the site that linked you to the original URL and let them know their link is broken.",
@ -129,5 +129,9 @@
"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.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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "ট্যাগ্স:",
"theme.tags.tagsPageLink": "সমস্ত ট্যাগ্স দেখুন",
"theme.tags.tagsPageTitle": "ট্যাগ্স"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tagy:",
"theme.tags.tagsPageLink": "Zobrazit všechny tagy",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Se alle Tags",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versionen",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Alle Tags anzeigen",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versiones",
"theme.tags.tagsListLabel": "Etiquetas:",
"theme.tags.tagsPageLink": "Ver Todas las Etiquetas",
"theme.tags.tagsPageTitle": "Etiquetas"
"theme.tags.tagsPageTitle": "Etiquetas",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "نسخه‌ها",
"theme.tags.tagsListLabel": "برچسب‌ها:",
"theme.tags.tagsPageLink": "مشاهده تمام برچسب‌ها",
"theme.tags.tagsPageTitle": "برچسب‌ها"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Mga Tag:",
"theme.tags.tagsPageLink": "Tingnan Lahat ng mga Tag",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tags :",
"theme.tags.tagsPageLink": "Voir tous les tags",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "תגיות:",
"theme.tags.tagsPageLink": "כל התגיות",
"theme.tags.tagsPageTitle": "תגיות"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "टैग:",
"theme.tags.tagsPageLink": "सारे टैग देखें",
"theme.tags.tagsPageTitle": "टैग"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versioni",
"theme.tags.tagsListLabel": "Etichette:",
"theme.tags.tagsPageLink": "Guarda tutte le etichette",
"theme.tags.tagsPageTitle": "Etichette"
"theme.tags.tagsPageTitle": "Etichette",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "他のバージョン",
"theme.tags.tagsListLabel": "タグ:",
"theme.tags.tagsPageLink": "全てのタグを見る",
"theme.tags.tagsPageTitle": "タグ"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "버전",
"theme.tags.tagsListLabel": "태그:",
"theme.tags.tagsPageLink": "모든 태그 보기",
"theme.tags.tagsPageTitle": "태그"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versies",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Laat alle tags zien",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Wersje",
"theme.tags.tagsListLabel": "Tagi:",
"theme.tags.tagsPageLink": "Wyświetl wszystkie tagi",
"theme.tags.tagsPageTitle": "Tagi"
"theme.tags.tagsPageTitle": "Tagi",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Marcadores:",
"theme.tags.tagsPageLink": "Ver todas os Marcadores",
"theme.tags.tagsPageTitle": "Marcadores"
"theme.tags.tagsPageTitle": "Marcadores",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versions",
"theme.tags.tagsListLabel": "Tags:",
"theme.tags.tagsPageLink": "Ver todas as Tags",
"theme.tags.tagsPageTitle": "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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Версии",
"theme.tags.tagsListLabel": "Теги:",
"theme.tags.tagsPageLink": "Посмотреть все теги",
"theme.tags.tagsPageTitle": "Теги"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Верзије",
"theme.tags.tagsListLabel": "Ознаке:",
"theme.tags.tagsPageLink": "Погледај све ознаке",
"theme.tags.tagsPageTitle": "Ознаке"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versioner",
"theme.tags.tagsListLabel": "Taggar:",
"theme.tags.tagsPageLink": "Visa Alla Taggar",
"theme.tags.tagsPageTitle": "Taggar"
"theme.tags.tagsPageTitle": "Taggar",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Versiyonlar",
"theme.tags.tagsListLabel": "Etiketler:",
"theme.tags.tagsPageLink": "Tüm Etiketleri Görüntüle",
"theme.tags.tagsPageTitle": "Etiketler"
"theme.tags.tagsPageTitle": "Etiketler",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Версії",
"theme.tags.tagsListLabel": "Теги:",
"theme.tags.tagsPageLink": "Переглянути всі теги",
"theme.tags.tagsPageTitle": "Теги"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "Phiên bản",
"theme.tags.tagsListLabel": "Thẻ:",
"theme.tags.tagsPageLink": "Xem tất cả Thẻ",
"theme.tags.tagsPageTitle": "Thẻ"
"theme.tags.tagsPageTitle": "Thẻ",
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "选择版本",
"theme.tags.tagsListLabel": "标签:",
"theme.tags.tagsPageLink": "查看所有标签",
"theme.tags.tagsPageTitle": "标签"
"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"
}

View file

@ -64,5 +64,7 @@
"theme.navbar.mobileVersionsDropdown.label": "選擇版本",
"theme.tags.tagsListLabel": "標籤:",
"theme.tags.tagsPageLink": "檢視所有標籤",
"theme.tags.tagsPageTitle": "標籤"
"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"
}

View file

@ -28,6 +28,16 @@ exports[`validation schemas admonitionsSchema: for value={"unknownAttribute":"va
exports[`validation schemas admonitionsSchema: for value=3 1`] = `""value" does not look like a valid admonitions config"`;
exports[`validation schemas contentVisibilitySchema: for value={"draft":"bad string"} 1`] = `""draft" must be a boolean"`;
exports[`validation schemas contentVisibilitySchema: for value={"draft":42} 1`] = `""draft" must be a boolean"`;
exports[`validation schemas contentVisibilitySchema: for value={"draft":true,"unlisted":true} 1`] = `"Can't be draft and unlisted at the same time."`;
exports[`validation schemas contentVisibilitySchema: for value={"unlisted":"bad string"} 1`] = `""unlisted" must be a boolean"`;
exports[`validation schemas contentVisibilitySchema: for value={"unlisted":42} 1`] = `""unlisted" must be a boolean"`;
exports[`validation schemas pathnameSchema: for value="foo" 1`] = `""value" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`;
exports[`validation schemas pathnameSchema: for value="https://github.com/foo" 1`] = `""value" is not a valid pathname. Pathname should start with slash and not contain any domain or query string."`;

View file

@ -14,6 +14,7 @@ import {
PluginIdSchema,
URISchema,
PathnameSchema,
ContentVisibilitySchema,
} from '../validationSchemas';
function createTestHelpers({
@ -166,4 +167,28 @@ describe('validation schemas', () => {
testFail('foo');
testFail('https://github.com/foo');
});
it('contentVisibilitySchema', () => {
const {testFail, testOK} = createTestHelpers({
schema: ContentVisibilitySchema,
});
testOK({});
testOK({draft: false});
testOK({draft: true});
testOK({unlisted: false});
testOK({unlisted: true});
testOK({draft: false, unlisted: false});
testOK({draft: true, unlisted: false});
testOK({draft: false, unlisted: true});
testOK({draft: true, unlisted: undefined});
testOK({draft: undefined, unlisted: true});
testFail({draft: 'bad string'});
testFail({draft: 42});
testFail({unlisted: 'bad string'});
testFail({unlisted: 42});
testFail({draft: true, unlisted: true});
});
});

View file

@ -24,4 +24,5 @@ export {
PathnameSchema,
FrontMatterTagsSchema,
FrontMatterTOCHeadingLevels,
ContentVisibilitySchema,
} from './validationSchemas';

View file

@ -126,3 +126,26 @@ export const FrontMatterTOCHeadingLevels = {
}),
toc_max_heading_level: JoiFrontMatter.number().min(2).max(6),
};
export type ContentVisibility = {
draft: boolean;
unlisted: boolean;
};
export const ContentVisibilitySchema = JoiFrontMatter.object<ContentVisibility>(
{
draft: JoiFrontMatter.boolean(),
unlisted: JoiFrontMatter.boolean(),
},
)
.custom((frontMatter: ContentVisibility, helpers) => {
if (frontMatter.draft && frontMatter.unlisted) {
return helpers.error('frontMatter.draftAndUnlistedError');
}
return frontMatter;
})
.messages({
'frontMatter.draftAndUnlistedError':
"Can't be draft and unlisted at the same time.",
})
.unknown();

View file

@ -5,7 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/
import {normalizeFrontMatterTags, groupTaggedItems, type Tag} from '../tags';
import {
normalizeFrontMatterTags,
groupTaggedItems,
type Tag,
getTagVisibility,
} from '../tags';
describe('normalizeFrontMatterTags', () => {
it('normalizes simple string tag', () => {
@ -183,3 +188,52 @@ describe('groupTaggedItems', () => {
expect(groupItems(input)).toEqual(expectedOutput);
});
});
describe('getTagVisibility', () => {
type Item = {id: string; unlisted: boolean};
function isUnlisted(item: Item): boolean {
return item.unlisted;
}
const item1: Item = {id: '1', unlisted: false};
const item2: Item = {id: '2', unlisted: true};
const item3: Item = {id: '3', unlisted: false};
const item4: Item = {id: '4', unlisted: true};
it('works for some unlisted', () => {
expect(
getTagVisibility({
items: [item1, item2, item3, item4],
isUnlisted,
}),
).toEqual({
listedItems: [item1, item3],
unlisted: false,
});
});
it('works for all unlisted', () => {
expect(
getTagVisibility({
items: [item2, item4],
isUnlisted,
}),
).toEqual({
listedItems: [item2, item4],
unlisted: true,
});
});
it('works for all listed', () => {
expect(
getTagVisibility({
items: [item1, item3],
isUnlisted,
}),
).toEqual({
listedItems: [item1, item3],
unlisted: false,
});
});
});

View file

@ -0,0 +1,54 @@
/**
* 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.
*/
type Env = 'production' | 'development';
/**
* To easily work on draft/unlisted in dev mode, use this env variable!
* SIMULATE_PRODUCTION_VISIBILITY=true yarn start:website
*/
const simulateProductionVisibility =
process.env.SIMULATE_PRODUCTION_VISIBILITY === 'true';
/**
* draft/unlisted is a production-only concept
* In dev it is ignored and all content files are included
*/
function isProduction(env: Env | undefined): boolean {
return (
simulateProductionVisibility ||
(env ?? process.env.NODE_ENV) === 'production'
);
}
/**
* A draft content will not be included in the production build
*/
export function isDraft({
frontMatter,
env,
}: {
frontMatter: {draft?: boolean};
env?: Env;
}): boolean {
return (isProduction(env) && frontMatter.draft) ?? false;
}
/**
* An unlisted content will be included in the production build, but hidden.
* It is excluded from sitemap, has noIndex, does not appear in lists etc...
* Only users having the link can find it.
*/
export function isUnlisted({
frontMatter,
env,
}: {
frontMatter: {unlisted?: boolean};
env?: Env;
}): boolean {
return (isProduction(env) && frontMatter.unlisted) ?? false;
}

View file

@ -62,6 +62,7 @@ export {
type FrontMatterTag,
normalizeFrontMatterTags,
groupTaggedItems,
getTagVisibility,
} from './tags';
export {
parseMarkdownHeadingId,
@ -103,3 +104,4 @@ export {
findFolderContainingFile,
getFolderContainingFile,
} from './dataFileUtils';
export {isDraft, isUnlisted} from './contentVisibilityUtils';

View file

@ -25,6 +25,8 @@ export type TagsListItem = Tag & {
export type TagModule = TagsListItem & {
/** The tags list page's permalink. */
allTagsPath: string;
/** Is this tag unlisted? (when it only contains unlisted items) */
unlisted: boolean;
};
export type FrontMatterTag = string | Tag;
@ -128,3 +130,32 @@ export function groupTaggedItems<Item>(
return result;
}
/**
* Permits to get the "tag visibility" (hard to find a better name)
* IE, is this tag listed or unlisted
* And which items should be listed when this tag is browsed
*/
export function getTagVisibility<Item>({
items,
isUnlisted,
}: {
items: Item[];
isUnlisted: (item: Item) => boolean;
}): {
unlisted: boolean;
listedItems: Item[];
} {
const allItemsUnlisted = items.every(isUnlisted);
// When a tag is full of unlisted items, we display all the items
// when tag is browsed, but we mark the tag as unlisted
if (allItemsUnlisted) {
return {unlisted: true, listedItems: items};
}
// When a tag has some listed items, the tag remains listed
// but we filter its unlisted items
return {
unlisted: false,
listedItems: items.filter((item) => !isUnlisted(item)),
};
}

View file

@ -359,6 +359,7 @@ typesense
unflat
unist
unlinkable
unlisteds
unlocalized
unmatch
unnormalized

View file

@ -0,0 +1,10 @@
---
title: Unlisted blog post
unlisted: true
tags: [blog, visibility, unlisted]
slug: /unlisted-post
---
This unlisted blog post should be "hidden" in production, but remain accessible.
It is filtered from the sidebar, sitemap, SEO indexation...

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