Commit 219bec9

HPCesia <me@hpcesia.com>
2025-10-14 16:48:16
feat: add FileTree component
1 parent aa93e9e
src/components/user/FileTree.astro
@@ -0,0 +1,50 @@
+---
+import { fileIcon } from '@utils/file-icon';
+import { Icon } from 'astro-icon/components';
+
+type FileNode =
+  | {
+      name: string;
+      children?: FileNode[];
+    }
+  | string;
+
+interface Props {
+  files: FileNode[];
+  open?: boolean;
+}
+
+const { files, open } = Astro.props;
+---
+
+<ul class="menu menu-sm rounded-box border-base-content/25 w-full border">
+  {
+    (() => {
+      function renderer(node: FileNode) {
+        if (typeof node === 'string') {
+          return (
+            <li>
+              <span class="cursor-auto">
+                <Icon name={fileIcon(node)} />
+                {node}
+              </span>
+            </li>
+          );
+        } else {
+          return (
+            <li>
+              <details {...(open ? { open: true } : {})}>
+                <summary>
+                  <Icon name="mdi:folder" />
+                  {node.name}
+                </summary>
+                {node.children && <ul>{node.children.map((child) => renderer(child))}</ul>}
+              </details>
+            </li>
+          );
+        }
+      }
+      return files.map((node) => renderer(node));
+    })()
+  }
+</ul>
src/components/user/index.ts
@@ -1,4 +1,5 @@
 export { default as Collapse } from './Collapse.astro';
+export { default as FileTree } from './FileTree.astro';
 export { default as Icon } from './Icon.astro';
 export { default as LinkCard } from './LinkCard.astro';
 export { default as Repl } from './Repl.astro';
src/content/posts/components.mdx
@@ -9,7 +9,7 @@ tags:
 published: 2025-02-10T21:23:23+08:00
 ---
 
-import { Collapse, Icon, LinkCard, Repl, RepoCard, Ruby, TabItem, Tabs, Tooltip } from '@components/user';
+import { Collapse, FileTree, Icon, LinkCard, Repl, RepoCard, Ruby, TabItem, Tabs, Tooltip } from '@components/user';
 
 Components let you easily reuse a piece of UI or styling consistently. You can use them not just in `.astro` files, but also in `.mdx` files.
 
@@ -145,6 +145,78 @@ Components let you easily reuse a piece of UI or styling consistently. You can u
   </Fragment>
 </Repl>
 
+### FileTree
+
+<Repl>
+  <FileTree
+    files={[
+      {
+        name: "src",
+        children: [
+          { name: "components", children: ["Button.astro", "Card.astro"] },
+          { name: "layouts", children: ["BaseLayout.astro"] },
+          { name: "pages", children: ["index.astro", "about.astro"] },
+        ],
+      },
+      { name: "public", children: ["favicon.ico", "robots.txt"] },
+      ".gitignore",
+      "astro.config.mjs",
+      "package.json",
+      "README.md",
+    ]}
+  />
+  <Fragment slot="desc">
+    <Tabs>
+      <TabItem label="mdx" active>
+        ```jsx
+        <FileTree
+          files={[
+            {
+              name: "src",
+              children: [
+                { name: "components", children: ["Button.astro", "Card.astro"] },
+                { name: "layouts", children: ["BaseLayout.astro"] },
+                { name: "pages", children: ["index.astro", "about.astro"] },
+              ],
+            },
+            { name: "public", children: ["favicon.ico", "robots.txt"] },
+            ".gitignore",
+            "astro.config.mjs",
+            "package.json",
+            "README.md",
+          ]}
+        />
+        ```
+      </TabItem>
+      <TabItem label="md">
+        ```md
+        :::filetree
+        - src
+          - components
+            - Button.astro
+            - Card.astro
+          - layouts
+            - BaseLayout.astro
+          - pages
+            - index.astro
+            - about.astro
+        - public
+          - favicon.ico
+          - robots.txt
+        - .gitignore
+        - astro.config.mjs
+        - package.json
+        - README.md
+        :::
+        ```
+      </TabItem>
+    </Tabs>
+  </Fragment>
+</Repl>
+
+> [!NOTE]
+> `FileTree` has an optional `open` prop for both `.mdx` and `.md` components to make the tree expanded by default.
+
 ## Inline Containers
 
 ### Tooltip
src/content/posts/manual.md
@@ -33,10 +33,12 @@ Your post files should be placed in `src/content/posts/` directory. You need to
 sub-directories if you want to use local assets. To automatically generate a new post file,
 run `pnpm new [draft|post] [title] [--dir]` in the terminal at the root of the project.
 
-```
-src/content/posts/
-├── post-1.md
-└── post-2/
-    ├── cover.png
-    └── index.md
-```
+:::filetree{open=true}
+
+- src/content/posts
+  - post-1.md
+  - post-2
+    - cover.png
+    - index.md
+
+:::
src/plugins/rehype-components-list.ts
@@ -1,9 +1,11 @@
 /**
  * All components in this file should sync with the components in `src/components/user`
  */
+import { fileIcon } from '../utils/file-icon.ts';
 import { getLinkPreview } from '../utils/link-preview.ts';
 import type { IconifyJSON } from '@iconify/types';
 import { getIconData, iconToHTML, iconToSVG, stringToIcon } from '@iconify/utils';
+import type { Element, ElementContent, Text } from 'hast';
 import { fromHtml } from 'hast-util-from-html';
 import { h } from 'hastscript';
 import type { Child } from 'hastscript';
@@ -72,6 +74,120 @@ const Collapse = function (
   return h('div', { class: wrapperClassName }, [inputNode, titleNode, contentNode]);
 };
 
+type ParsedFileNode =
+  | string
+  | {
+      name: string;
+      children: ParsedFileNode[];
+    };
+
+function isElementNode(value: ElementContent): value is Element {
+  return value.type === 'element';
+}
+
+function isTextNode(value: ElementContent): value is Text {
+  return value.type === 'text';
+}
+
+function extractText(value: ElementContent): string {
+  if (isTextNode(value)) {
+    return value.value;
+  }
+
+  if (isElementNode(value)) {
+    if (value.tagName === 'ul') return '';
+    return (value.children ?? []).map(extractText).join('');
+  }
+
+  return '';
+}
+
+function normalizeName(raw: string): string {
+  return raw.replace(/\s+/g, ' ').trim();
+}
+
+function parseListElement(list: Element): ParsedFileNode[] {
+  const items: ParsedFileNode[] = [];
+
+  for (const child of list.children ?? []) {
+    if (!isElementNode(child) || child.tagName !== 'li') continue;
+
+    let nested: ParsedFileNode[] = [];
+    const nameParts: string[] = [];
+
+    for (const itemChild of child.children ?? []) {
+      if (isElementNode(itemChild) && itemChild.tagName === 'ul') {
+        nested = parseListElement(itemChild);
+        continue;
+      }
+
+      const piece = extractText(itemChild);
+      if (piece.trim().length > 0) {
+        nameParts.push(piece);
+      }
+    }
+
+    const name = normalizeName(nameParts.join(' '));
+    if (!name) continue;
+
+    if (nested.length > 0) {
+      items.push({ name, children: nested });
+    } else {
+      items.push(name);
+    }
+  }
+
+  return items;
+}
+
+function renderFileNode(node: ParsedFileNode, open: boolean): Child {
+  if (typeof node === 'string') {
+    return h(
+      'li',
+      h('span', { class: 'cursor-auto' }, Icon({ name: fileIcon(node) }), ' ', node)
+    );
+  }
+
+  const nestedChildren =
+    node.children.length > 0
+      ? [h('ul', ...node.children.map((child) => renderFileNode(child, open)))]
+      : [];
+
+  return h(
+    'li',
+    h(
+      'details',
+      { ...(open ? { open: '' } : {}) },
+      h('summary', Icon({ name: 'mdi:folder' }), ' ', node.name),
+      ...nestedChildren
+    )
+  );
+}
+
+const FileTree = function (props: { open?: boolean }, children: ElementContent[]) {
+  const listElement = children.find(
+    (child): child is Element => isElementNode(child) && child.tagName === 'ul'
+  );
+
+  if (!listElement) {
+    console.warn('[WARN] FileTree directive expects a nested list as its content.');
+    return children;
+  }
+
+  const parsed = parseListElement(listElement);
+
+  if (parsed.length === 0) {
+    console.warn('[WARN] FileTree directive content is empty.');
+    return children;
+  }
+
+  return h(
+    'ul',
+    { class: 'menu menu-sm rounded-box border-base-content/25 w-full border' },
+    ...parsed.map((node) => renderFileNode(node, props.open ?? false))
+  );
+};
+
 const Icon = function (props: {
   name: string;
   size?:
@@ -316,6 +432,7 @@ const Tooltip = function (
 
 export const rehypeComponentsList = {
   collapse: Collapse,
+  filetree: FileTree,
   icon: Icon,
   linkcard: LinkCard,
   rubyc: Ruby,
src/styles/markdown.css
@@ -102,7 +102,7 @@ article {
   }
 
   /* 列表样式 */
-  ul,
+  ul:not(.menu):not(.menu *),
   ol {
     margin-top: 0.5rem;
   }
@@ -136,7 +136,7 @@ article {
     }
   }
 
-  ul > li {
+  ul:not(.menu):not(.menu *) > li {
     position: relative;
     padding: 0.25rem 0.25rem 0.25rem 1.4em;
 
src/utils/file-icon.ts
@@ -0,0 +1,115 @@
+const imageExtensions = [
+  'avif',
+  'bmp',
+  'gif',
+  'ico',
+  'jpeg',
+  'jpg',
+  'png',
+  'svg',
+  'tiff',
+  'webp',
+];
+
+const documentExtensions = [
+  'doc',
+  'docx',
+  'equb',
+  'log',
+  'md',
+  'mdoc',
+  'mdx',
+  'pdf',
+  'ppt',
+  'pptx',
+  'txt',
+  'wps',
+  'xls',
+  'xlsx',
+];
+
+const codeExtensions = [
+  'astro',
+  'bat',
+  'c',
+  'cjs',
+  'cmd',
+  'cpp',
+  'cs',
+  'css',
+  'csv',
+  'dart',
+  'ejs',
+  'fish',
+  'go',
+  'h',
+  'hpp',
+  'hs',
+  'html',
+  'ini',
+  'java',
+  'js',
+  'json',
+  'kt',
+  'kts',
+  'less',
+  'log',
+  'lua',
+  'm',
+  'makefile',
+  'mjs',
+  'nu',
+  'php',
+  'plist',
+  'ps1',
+  'py',
+  'r',
+  'rb',
+  'rs',
+  'scss',
+  'sh',
+  'styl',
+  'svelte',
+  'swift',
+  'toml',
+  'ts',
+  'tsx',
+  'typ',
+  'typst',
+  'vue',
+  'xml',
+  'yml',
+  'yaml',
+  'zsh',
+  'zig',
+];
+
+const gitRelatedExtensions = ['gitattributes', 'gitignore', 'gitkeep', 'gitmodules'];
+
+// Map extensions to icons
+
+const iconMap: Record<string, string> = {};
+
+imageExtensions.forEach((ext) => {
+  iconMap[ext] = 'mdi:file-image';
+});
+
+documentExtensions.forEach((ext) => {
+  iconMap[ext] = 'mdi:file-document';
+});
+
+codeExtensions.forEach((ext) => {
+  iconMap[ext] = 'mdi:file-code';
+});
+
+gitRelatedExtensions.forEach((ext) => {
+  iconMap[ext] = 'mdi:git';
+});
+
+// Default icon
+const defaultIcon = 'mdi:file';
+
+export function fileIcon(fileName: string) {
+  const ext = fileName.split('.').pop()?.toLowerCase();
+  return ext && iconMap[ext] ? iconMap[ext] : defaultIcon;
+}