From d9dfc9f09afeef757a4464e27077b08769802e18 Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Tue, 27 Feb 2024 12:28:09 +0100 Subject: [PATCH] refactor: use rehype in fragment mode Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> --- .../user-components/rehype-file-tree.ts | 194 +++++++++--------- 1 file changed, 100 insertions(+), 94 deletions(-) diff --git a/packages/starlight/user-components/rehype-file-tree.ts b/packages/starlight/user-components/rehype-file-tree.ts index 0dc98707eea..5af454fe32d 100644 --- a/packages/starlight/user-components/rehype-file-tree.ts +++ b/packages/starlight/user-components/rehype-file-tree.ts @@ -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'; @@ -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 { @@ -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) { @@ -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(