@astrojs/starlight
Version:
Build beautiful, high-performance documentation websites with Astro
252 lines (213 loc) • 8.72 kB
text/typescript
import { AstroError } from 'astro/errors';
import type { Element, ElementContent, Text } from 'hast';
import { type Child, h, s } from 'hastscript';
import { select } from 'hast-util-select';
import { fromHtml } from 'hast-util-from-html';
import { toString } from 'hast-util-to-string';
import { rehype } from 'rehype';
import { CONTINUE, SKIP, visit } from 'unist-util-visit';
import { Icons, type StarlightIcon } from '../components/Icons';
import { definitions } from './file-tree-icons';
declare module 'vfile' {
interface DataMap {
directoryLabel: string;
}
}
const folderIcon = makeSVGIcon(Icons['seti:folder']);
const defaultFileIcon = makeSVGIcon(Icons['seti:default']);
/**
* Process the HTML for a file tree to create the necessary markup for each file and directory
* including icons.
* @param html Inner HTML passed to the `<FileTree>` component.
* @param directoryLabel The localized label for a directory.
* @returns The processed HTML for the file tree.
*/
export function processFileTree(html: string, directoryLabel: string) {
const file = fileTreeProcessor.processSync({ data: { directoryLabel }, value: html });
return file.toString();
}
/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
const fileTreeProcessor = rehype()
.data('settings', { fragment: true })
.use(function fileTree() {
return (tree: Element, file) => {
const { directoryLabel } = file.data;
validateFileTree(tree);
visit(tree, 'element', (node) => {
// Strip nodes that only contain newlines.
node.children = node.children.filter(
(child) => child.type === 'comment' || child.type !== 'text' || !/^\n+$/.test(child.value)
);
// Skip over non-list items.
if (node.tagName !== 'li') return CONTINUE;
const [firstChild, ...otherChildren] = node.children;
// Keep track of comments associated with the current file or directory.
const comment: Child[] = [];
// Extract text comment that follows the file name, e.g. `README.md This is a comment`
if (firstChild?.type === 'text') {
const [filename, ...fragments] = firstChild.value.split(' ');
firstChild.value = filename || '';
const textComment = fragments.join(' ').trim();
if (textComment.length > 0) {
comment.push(fragments.join(' '));
}
}
// Comments may not always be entirely part of the first child text node,
// e.g. `README.md This is an __important__ comment` where the `__important__` and `comment`
// nodes would also be children of the list item node.
const subTreeIndex = otherChildren.findIndex(
(child) => child.type === 'element' && child.tagName === 'ul'
);
const commentNodes =
subTreeIndex > -1 ? otherChildren.slice(0, subTreeIndex) : [...otherChildren];
otherChildren.splice(0, subTreeIndex > -1 ? subTreeIndex : otherChildren.length);
comment.push(...commentNodes);
const firstChildTextContent = firstChild ? toString(firstChild) : '';
// Decide a node is a directory if it ends in a `/` or contains another list.
const isDirectory =
/\/\s*$/.test(firstChildTextContent) ||
otherChildren.some((child) => child.type === 'element' && child.tagName === 'ul');
// A placeholder is a node that only contains 3 dots or an ellipsis.
const isPlaceholder = /^\s*(\.{3}|…)\s*$/.test(firstChildTextContent);
// A node is highlighted if its first child is bold text, e.g. `**README.md**`.
const isHighlighted = firstChild?.type === 'element' && firstChild.tagName === 'strong';
// Create an icon for the file or directory (placeholder do not have icons).
const icon = h('span', isDirectory ? folderIcon : getFileIcon(firstChildTextContent));
if (isDirectory) {
// Add a screen reader only label for directories before the icon so that it is announced
// as such before reading the directory name.
icon.children.unshift(h('span', { class: 'sr-only' }, directoryLabel));
}
// Add classes and data attributes to the list item node.
node.properties.class = isDirectory ? 'directory' : 'file';
if (isPlaceholder) node.properties.class += ' empty';
// Create the tree entry node that contains the icon, file name and comment which will end up
// as the list item’s children.
const treeEntryChildren: Child[] = [
h('span', { class: isHighlighted ? 'highlight' : '' }, [
isPlaceholder ? null : icon,
firstChild,
]),
];
if (comment.length > 0) {
treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
}
const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);
if (isDirectory) {
const hasContents = otherChildren.length > 0;
node.children = [
h('details', { open: hasContents }, [
h('summary', treeEntry),
...(hasContents ? otherChildren : [h('ul', h('li', '…'))]),
]),
];
// Continue down the tree.
return CONTINUE;
}
node.children = [treeEntry, ...otherChildren];
// Files can’t contain further files or directories, so skip iterating children.
return SKIP;
});
};
});
/** Make a text node with the pass string as its contents. */
function makeText(value = ''): Text {
return { type: 'text', value };
}
/** Make a node containing an SVG icon from the passed HTML string. */
function makeSVGIcon(svgString: string) {
return s(
'svg',
{
width: 16,
height: 16,
class: 'tree-icon',
'aria-hidden': 'true',
viewBox: '0 0 24 24',
},
fromHtml(svgString, { fragment: true })
);
}
/** Return the icon for a file based on its file name. */
function getFileIcon(fileName: string) {
const name = getFileIconName(fileName);
if (!name) return defaultFileIcon;
if (name in Icons) {
const path = Icons[name as StarlightIcon];
return makeSVGIcon(path);
}
return defaultFileIcon;
}
/** Return the icon name for a file based on its file name. */
function getFileIconName(fileName: string) {
let icon: string | undefined = definitions.files[fileName];
if (icon) return icon;
icon = getFileIconTypeFromExtension(fileName);
if (icon) return icon;
for (const [partial, partialIcon] of Object.entries(definitions.partials)) {
if (fileName.includes(partial)) return partialIcon;
}
return icon;
}
/**
* Get an icon from a file name based on its extension.
* Note that an extension in Seti is everything after a dot, so `README.md` would be `.md` and
* `name.with.dots` will try to look for an icon for `.with.dots` and then `.dots` if the first one
* is not found.
*/
function getFileIconTypeFromExtension(fileName: string) {
const firstDotIndex = fileName.indexOf('.');
if (firstDotIndex === -1) return;
let extension = fileName.slice(firstDotIndex);
while (extension !== '') {
const icon = definitions.extensions[extension];
if (icon) return icon;
const nextDotIndex = extension.indexOf('.', 1);
if (nextDotIndex === -1) return;
extension = extension.slice(nextDotIndex);
}
return;
}
/** Validate that the user provided HTML for a file tree is valid. */
function validateFileTree(tree: Element) {
const rootElements = tree.children.filter(isElementNode);
const [rootElement] = rootElements;
if (rootElements.length === 0) {
throwFileTreeValidationError(
'The `<FileTree>` component expects its content to be a single unordered list but found no child elements.'
);
}
if (rootElements.length !== 1) {
throwFileTreeValidationError(
`The \`<FileTree>\` component expects its content to be a single unordered list but found multiple child elements: ${rootElements
.map((element) => `\`<${element.tagName}>\``)
.join(' - ')}.`
);
}
if (!rootElement || rootElement.tagName !== 'ul') {
throwFileTreeValidationError(
`The \`<FileTree>\` component expects its content to be an unordered list but found the following element: \`<${rootElement?.tagName}>\`.`
);
}
const listItemElement = select('li', rootElement);
if (!listItemElement) {
throwFileTreeValidationError(
'The `<FileTree>` component expects its content to be an unordered list with at least one list item.'
);
}
}
function isElementNode(node: ElementContent): node is Element {
return node.type === 'element';
}
/** Throw a validation error for a file tree linking to the documentation. */
function throwFileTreeValidationError(message: string): never {
throw new AstroError(
message,
'To learn more about the `<FileTree>` component, see https://starlight.astro.build/components/file-tree/'
);
}
export interface Definitions {
files: Record<string, string>;
extensions: Record<string, string>;
partials: Record<string, string>;
}