Skip to content

Commit

Permalink
refactor: use rehype in fragment mode
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
  • Loading branch information
HiDeoo and delucis committed Feb 27, 2024
1 parent 311b109 commit d9dfc9f
Showing 1 changed file with 100 additions and 94 deletions.
194 changes: 100 additions & 94 deletions packages/starlight/user-components/rehype-file-tree.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { AstroError } from 'astro/errors';
import type { Element, Text } from 'hast';
import type { Element, ElementContent, Text } from 'hast';
import { type Child, h, s } from 'hastscript';
import { select, selectAll } from 'hast-util-select';
import { select } from 'hast-util-select';
import { fromHtml } from 'hast-util-from-html';
import { toString } from 'hast-util-to-string';
import { rehype } from 'rehype';
Expand Down Expand Up @@ -32,106 +32,108 @@ export function processFileTree(html: string, directoryLabel: string) {
}

/** Rehype processor to extract file tree data and turn each entry into its associated markup. */
const fileTreeProcessor = rehype().use(function fileTree() {
return (tree: Element, file) => {
const { directoryLabel } = file.data;
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(' '));
}
}

validateFileTree(tree);
// 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));
}

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)
);
// Add classes and data attributes to the list item node.
node.properties.class = isDirectory ? 'directory' : 'file';
if (isPlaceholder) node.properties.class += ' empty';

// Skip over non-list items.
if (node.tagName !== 'li') return CONTINUE;
// 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,
]),
];

const [firstChild, ...otherChildren] = node.children;
if (comment.length > 0) {
treeEntryChildren.push(makeText(' '), h('span', { class: 'comment' }, ...comment));
}

// Keep track of comments associated with the current file or directory.
const comment: Child[] = [];
const treeEntry = h('span', { class: 'tree-entry' }, ...treeEntryChildren);

// 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', '…'))]),
]),
];
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;
}
// Continue down the tree.
return CONTINUE;
}

node.children = [treeEntry, ...otherChildren];
node.children = [treeEntry, ...otherChildren];

// Files can’t contain further files or directories, so skip iterating children.
return SKIP;
});
};
});
// 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 {
Expand Down Expand Up @@ -198,7 +200,7 @@ function getFileIconTypeFromExtension(fileName: string) {

/** Validate that the user provided HTML for a file tree is valid. */
function validateFileTree(tree: Element) {
const rootElements = selectAll('body > *', tree);
const rootElements = tree.children.filter(isElementNode);
const [rootElement] = rootElements;

if (rootElements.length === 0) {
Expand Down Expand Up @@ -230,6 +232,10 @@ function validateFileTree(tree: Element) {
}
}

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(
Expand Down

0 comments on commit d9dfc9f

Please sign in to comment.