Commit 219bec9
Changed files (7)
src
components
user
content
posts
plugins
styles
utils
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;
+}