@docusaurus/utils
Version:
Node utility functions for Docusaurus packages.
248 lines (224 loc) • 6.89 kB
text/typescript
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {normalizeUrl} from './urlUtils';
import type {Optional} from 'utility-types';
export type Tag = {
/** The display label of a tag */
label: string;
/** Permalink to this tag's page, without the `/tags/` base path. */
permalink: string;
/** An optional description of the tag */
description: string | undefined;
};
export type TagsFileInput = Record<string, Partial<Tag> | null>;
export type TagsFile = Record<string, Tag>;
// Tags plugins options shared between docs/blog
export type TagsPluginOptions = {
// TODO allow option tags later? | TagsFile;
/** Path to the tags file. */
tags: string | false | null | undefined;
/** The behavior of Docusaurus when it finds inline tags. */
onInlineTags: 'ignore' | 'log' | 'warn' | 'throw';
};
export type TagMetadata = Tag & {
inline: boolean;
};
/** What the tags list page should know about each tag. */
export type TagsListItem = Tag & {
/** Number of posts/docs with this tag. */
count: number;
};
/** What the tag's own page should know about the tag. */
export type TagModule = TagsListItem & {
/** The tags list page's permalink. */
// TODO move this global value to a shared docs/blog bundle
allTagsPath: string;
/** Is this tag unlisted? (when it only contains unlisted items) */
unlisted: boolean;
};
export type FrontMatterTag = string | Optional<Tag, 'description'>;
// We always apply tagsBaseRoutePath on purpose. For versioned docs, v1/doc.md
// and v2/doc.md tags with custom permalinks don't lead to the same created
// page. tagsBaseRoutePath is different for each doc version
function normalizeTagPermalink({
tagsBaseRoutePath,
permalink,
}: {
tagsBaseRoutePath: string;
permalink: string;
}): string {
return normalizeUrl([tagsBaseRoutePath, permalink]);
}
function normalizeInlineTag(
tagsBaseRoutePath: string,
frontMatterTag: FrontMatterTag,
): TagMetadata {
function toTagObject(tagString: string): TagMetadata {
return {
inline: true,
label: tagString,
permalink: _.kebabCase(tagString),
description: undefined,
};
}
const tag: Tag =
typeof frontMatterTag === 'string'
? toTagObject(frontMatterTag)
: {...frontMatterTag, description: frontMatterTag.description};
return {
inline: true,
label: tag.label,
permalink: normalizeTagPermalink({
permalink: tag.permalink,
tagsBaseRoutePath,
}),
description: tag.description,
};
}
export function normalizeTag({
tag,
tagsFile,
tagsBaseRoutePath,
}: {
tag: FrontMatterTag;
tagsBaseRoutePath: string;
tagsFile: TagsFile | null;
}): TagMetadata {
if (typeof tag === 'string') {
const tagDescription = tagsFile?.[tag];
if (tagDescription) {
// pre-defined tag from tags.yml
return {
inline: false,
label: tagDescription.label,
permalink: normalizeTagPermalink({
permalink: tagDescription.permalink,
tagsBaseRoutePath,
}),
description: tagDescription.description,
};
}
}
// legacy inline tag object, always inline, unknown because isn't a string
return normalizeInlineTag(tagsBaseRoutePath, tag);
}
export function normalizeTags({
options,
source,
frontMatterTags,
tagsBaseRoutePath,
tagsFile,
}: {
options: TagsPluginOptions;
source: string;
frontMatterTags: FrontMatterTag[] | undefined;
tagsBaseRoutePath: string;
tagsFile: TagsFile | null;
}): TagMetadata[] {
const tags = (frontMatterTags ?? []).map((tag) =>
normalizeTag({tag, tagsBaseRoutePath, tagsFile}),
);
if (tagsFile !== null) {
reportInlineTags({tags, source, options});
}
return tags;
}
export function reportInlineTags({
tags,
source,
options,
}: {
tags: TagMetadata[];
source: string;
options: TagsPluginOptions;
}): void {
if (options.onInlineTags === 'ignore') {
return;
}
const inlineTags = tags.filter((tag) => tag.inline);
if (inlineTags.length > 0) {
const uniqueUnknownTags = [...new Set(inlineTags.map((tag) => tag.label))];
const tagListString = uniqueUnknownTags.join(', ');
logger.report(options.onInlineTags)(
`Tags [${tagListString}] used in ${source} are not defined in ${
options.tags ?? 'tags.yml'
}`,
);
}
}
type TaggedItemGroup<Item> = {
tag: TagMetadata;
items: Item[];
};
/**
* Permits to group docs/blog posts by tag (provided by front matter).
*
* @returns a map from tag permalink to the items and other relevant tag data.
* The record is indexed by permalink, because routes must be unique in the end.
* Labels may vary on 2 MD files but they are normalized. Docs with
* label='some label' and label='some-label' should end up in the same page.
*/
export function groupTaggedItems<Item>(
items: readonly Item[],
/**
* A callback telling me how to get the tags list of the current item. Usually
* simply getting it from some metadata of the current item.
*/
getItemTags: (item: Item) => readonly TagMetadata[],
): {[permalink: string]: TaggedItemGroup<Item>} {
const result: {[permalink: string]: TaggedItemGroup<Item>} = {};
items.forEach((item) => {
getItemTags(item).forEach((tag) => {
// Init missing tag groups
// TODO: it's not really clear what should be the behavior if 2 tags have
// the same permalink but the label is different for each
// For now, the first tag found wins
result[tag.permalink] ??= {
tag,
items: [],
};
// Add item to group
result[tag.permalink]!.items.push(item);
});
});
// If user add twice the same tag to a md doc (weird but possible),
// we don't want the item to appear twice in the list...
Object.values(result).forEach((group) => {
group.items = _.uniq(group.items);
});
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)),
};
}