UNPKG

starlight-ion-theme

Version:

![Ion](./assets/showcase.png)

279 lines (239 loc) 9.45 kB
import { AstroError } from "astro/errors"; import type { Element, ElementContent, Text } from "hast"; import { fromHtml } from "hast-util-from-html"; import { select } from "hast-util-select"; import { toString } from "hast-util-to-string"; import { type Child, h, s } from "hastscript"; import { rehype } from "rehype"; import { CONTINUE, SKIP, visit } from "unist-util-visit"; import { Icons } from "./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 as any) ); } // 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 keyof typeof Icons]; 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/guides/components/#file-tree" ); } export interface Definitions { files: Record<string, string>; extensions: Record<string, string>; partials: Record<string, string>; }