diff --git a/README.md b/README.md index b6343408..728169b3 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Click the following links to generate a new repository with template: - **Diagrams** code fences extended and shortcode supported by [mermaid](https://github.com/knsv/mermaid) - **Interactive data visualization** shortcode and code fences extended supported by [ECharts](https://echarts.apache.org/) - **Timeline** code fences extended and shortcode supported +- **File tree** code fences extended and shortcode supported - **Mapbox** shortcode supported by [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js) - **Music player** shortcode supported by [APlayer](https://github.com/MoePlayer/APlayer) and [MetingJS](https://github.com/metowolf/MetingJS) - **Bilibili player** and **Douyin player** shortcode diff --git a/README.zh-cn.md b/README.zh-cn.md index dca40eee..272c9e8e 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -147,6 +147,7 @@ pnpx fixit-cli create my-blog - 支持基于 [mermaid](https://github.com/knsv/mermaid) 的**图表** 代码块扩展语法和 shortcode - 支持基于 [ECharts](https://echarts.apache.org/) 的**交互式数据可视化** 代码块扩展语法和 shortcode - 支持 **Timeline** 的代码块扩展语法和 shortcode +- 支持 **文件树** 的代码块扩展语法和 shortcode - 支持基于 [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js) 的 **Mapbox** shortcode - 支持基于 [APlayer](https://github.com/MoePlayer/APlayer) 和 [MetingJS](https://github.com/metowolf/MetingJS) 的**音乐播放器** shortcode - 支持 **Bilibili 视频** 和 **抖音视频** shortcode diff --git a/apps/test/assets/filetree/example.json b/apps/test/assets/filetree/example.json new file mode 100644 index 00000000..0324cf25 --- /dev/null +++ b/apps/test/assets/filetree/example.json @@ -0,0 +1,72 @@ +[ + { + "name": "src-json", + "type": "dir", + "children": [ + { + "name": "index.ts", + "type": "file" + }, + { + "name": "components", + "type": "dir", + "children": [ + { + "name": "Header.tsx", + "type": "file" + }, + { + "name": "Footer.tsx", + "type": "file" + } + ] + }, + { + "name": "utils", + "type": "dir", + "children": [ + { + "name": "helper.ts", + "type": "file" + }, + { + "name": "validator.ts", + "type": "file" + } + ] + } + ] + }, + { + "name": "public", + "type": "dir", + "children": [ + { + "name": "favicon.ico", + "type": "file" + }, + { + "name": "images", + "type": "dir", + "children": [ + { + "name": "logo.png", + "type": "file" + } + ] + } + ] + }, + { + "name": "package.json", + "type": "file" + }, + { + "name": "README.md", + "type": "file" + }, + { + "name": "tsconfig.json", + "type": "file" + } +] diff --git a/apps/test/content/posts/file-tree-test/data/example.toml b/apps/test/content/posts/file-tree-test/data/example.toml new file mode 100644 index 00000000..8a6aaf90 --- /dev/null +++ b/apps/test/content/posts/file-tree-test/data/example.toml @@ -0,0 +1,59 @@ +[[filetree]] +name = "src-toml" +type = "dir" + +[[filetree.children]] +name = "index.ts" +type = "file" + +[[filetree.children]] +name = "components" +type = "dir" + +[[filetree.children.children]] +name = "Header.tsx" +type = "file" + +[[filetree.children.children]] +name = "Footer.tsx" +type = "file" + +[[filetree.children]] +name = "utils" +type = "dir" + +[[filetree.children.children]] +name = "helper.ts" +type = "file" + +[[filetree.children.children]] +name = "validator.ts" +type = "file" + +[[filetree]] +name = "public" +type = "dir" + +[[filetree.children]] +name = "favicon.ico" +type = "file" + +[[filetree.children]] +name = "images" +type = "dir" + +[[filetree.children.children]] +name = "logo.png" +type = "file" + +[[filetree]] +name = "package.json" +type = "file" + +[[filetree]] +name = "README.md" +type = "file" + +[[filetree]] +name = "tsconfig.json" +type = "file" diff --git a/apps/test/content/posts/file-tree-test/index.md b/apps/test/content/posts/file-tree-test/index.md new file mode 100644 index 00000000..4f2ce77d --- /dev/null +++ b/apps/test/content/posts/file-tree-test/index.md @@ -0,0 +1,267 @@ +--- +title: File Tree Test +date: 2026-01-10T03:00:48+08:00 +collections: + - Tests +categories: + - Shortcodes +tags: + - file-tree +--- + +This article tests the **File Tree** shortcode functionality in the FixIt theme. + + + + +## Content Modes + +### Shortcode body + +#### JSON format + +{{< file-tree >}} +[ + { + "name": "src", + "type": "dir", + "children": [ + { + "name": "index.ts", + "type": "file" + }, + { + "name": "app.ts", + "type": "file" + } + ] + }, + { + "name": "README.md", + "type": "file" + }, + { + "name": ".gitignore", + "type": "file" + } +] +{{< /file-tree >}} + +#### YAML format + +{{< file-tree folderSlash=true >}} +- name: project-root + type: dir + children: + - name: src + type: dir + children: + - name: index.ts + type: file + - name: config.ts + type: file + - name: tests + type: dir + children: + - name: unit.test.ts + type: file + - name: package.json + type: file + - name: tsconfig.json + type: file +{{< /file-tree >}} + +#### TOML format + +{{< file-tree >}} +[[filetree]] +name = "src" +type = "dir" + +[[filetree.children]] +name = "index.ts" +type = "file" + +[[filetree.children]] +name = "app.ts" +type = "file" + +[[filetree]] +name = "README.md" +type = "file" +{{< /file-tree >}} + +### File data + +#### TOML format + +{{< file-tree file="data/example.toml" />}} + +TOML with folder slash and level 3: + +{{< file-tree file="data/example.toml" level=3 folderSlash=true />}} + +#### JSON format + +{{< file-tree file="filetree/example.json" />}} + +JSON with folder slash and level 2: + +{{< file-tree file="filetree/example.json" level=2 folderSlash=true />}} + +#### YAML format + +Trying the file not found case (should fallback to next mode): + +{{< file-tree file="data/nonexistent.yaml" data="example" />}} + +### Site data + +#### Basic usage + +{{< file-tree data="example" />}} + +#### Fully expanded + +{{< file-tree data="example" level=-1 />}} + +#### Limited expansion + +{{< file-tree data="example" level=1 />}} + +#### With folder slash + +{{< file-tree data="example" folderSlash=true />}} + +#### With level and folder slash + +{{< file-tree data="example" level=2 folderSlash=true />}} + +#### Collapsed with ignore + +{{< file-tree data="example" level=0 ignoreList="README.md" />}} + +## Filesystem Mode + +### Basic paths + +{{< file-tree />}} + +{{< file-tree "." />}} + +{{< file-tree "content" />}} + +### Named parameters + +{{< file-tree path="data" level=2 />}} + +### Expansion levels + +{{< file-tree "assets" 0 />}} + +{{< file-tree path="assets" level=2 folderSlash=true />}} + +{{< file-tree "assets" -1 />}} + +### With ignore lists + +{{< file-tree ignoreList=".hugo_build.lock, public, resources, node_modules" level=2 folderSlash=true />}} + +### root not exist + +path="nonexistent/path" + +{{< file-tree "nonexistent/path" />}} + +## Code Block Rendering + +### Basic code block + +YAML format: + +```file-tree +- name: my-project + type: dir + children: + - name: src + type: dir + children: + - name: main.js + type: file + - name: utils.js + type: file + - name: package.json + type: file + - name: README.md + type: file +``` + +### TOML format + +```file-tree +[[filetree]] +name = "src" +type = "dir" + +[[filetree.children]] +name = "index.ts" +type = "file" + +[[filetree]] +name = "README.md" +type = "file" +``` + +### With parameters + +```file-tree {level=2 folderSlash=true ignoreList="config.local.js"} +- name: project + type: dir + children: + - name: config + type: dir + children: + - name: config.js + type: file + - name: config.local.js + type: file + - name: src + type: dir + children: + - name: index.js + type: file + - name: .gitignore + type: file +``` + +### Fully expanded + +```file-tree {level=-1 folderSlash=true} +- name: backend + type: dir + children: + - name: api + type: dir + children: + - name: routes + type: dir + children: + - name: users.js + type: file + - name: posts.js + type: file + - name: controllers + type: dir + children: + - name: userController.js + type: file + - name: database + type: dir + children: + - name: models + type: dir + - name: migrations + type: dir + - name: app.js + type: file +``` diff --git a/apps/test/content/posts/tabs-comprehensive-test.md b/apps/test/content/posts/tabs-comprehensive-test.md index 6aca7a47..5adbd7e8 100644 --- a/apps/test/content/posts/tabs-comprehensive-test.md +++ b/apps/test/content/posts/tabs-comprehensive-test.md @@ -178,3 +178,42 @@ gantt {{% /tab %}} {{< /tabs >}} + +### File Tree inside Tabs + +{{< tabs >}} +{{% tab title="File Tree Shortcode" %}} + +{{< file-tree >}} +[[filetree]] +name = "src" +type = "dir" + +[[filetree.children]] +name = "index.ts" +type = "file" + +[[filetree]] +name = "README.md" +type = "file" +{{< /file-tree >}} + +{{% /tab %}} +{{% tab title="File Tree Code Block" %}} + +```file-tree +[[filetree]] +name = "src" +type = "dir" + +[[filetree.children]] +name = "index.ts" +type = "file" + +[[filetree]] +name = "README.md" +type = "file" +``` + +{{% /tab %}} +{{< /tabs >}} diff --git a/apps/test/data/filetree/example.yml b/apps/test/data/filetree/example.yml new file mode 100644 index 00000000..70f5ab87 --- /dev/null +++ b/apps/test/data/filetree/example.yml @@ -0,0 +1,38 @@ +- name: my-project + type: dir + children: + - name: src + type: dir + children: + - name: index.ts + type: file + - name: components + type: dir + children: + - name: Header.tsx + type: file + - name: Footer.tsx + type: file + - name: utils + type: dir + children: + - name: helper.ts + type: file + - name: validator.ts + type: file + - name: public + type: dir + children: + - name: favicon.ico + type: file + - name: images + type: dir + children: + - name: logo.png + type: file + - name: package.json + type: file + - name: README.md + type: file + - name: tsconfig.json + type: file diff --git a/apps/test/hugo.toml b/apps/test/hugo.toml index b8ab35b6..7c0bf6a6 100644 --- a/apps/test/hugo.toml +++ b/apps/test/hugo.toml @@ -5,6 +5,7 @@ title = "Hugo FixIt TEST" baseURL = "https://demo.fixit.lruihao.cn/test/" +ignoreLogs = [ "warning-file-tree" ] [permalinks] diff --git a/assets/css/_page/_single.scss b/assets/css/_page/_single.scss index 4fdf55b9..3bf0c734 100644 --- a/assets/css/_page/_single.scss +++ b/assets/css/_page/_single.scss @@ -510,7 +510,8 @@ margin-top: 0.5rem; } } - + + // TODO Reorganization import @import '../_shortcodes'; @import '../_partials/_single/alert'; @import '../_partials/_single/code'; diff --git a/assets/css/_shortcodes/_file-tree.scss b/assets/css/_shortcodes/_file-tree.scss new file mode 100644 index 00000000..182d642d --- /dev/null +++ b/assets/css/_shortcodes/_file-tree.scss @@ -0,0 +1,109 @@ +.file-tree-wrapper { + border: 1px solid var(#{$rootPrefix}global-border-color); + padding: 0.5rem; + font-size: 0.8rem; + line-height: 1; + margin-block: 0.5rem; + @include border-radius($global-border-radius); + @include transition(box-shadow 0.2s ease); + + &:hover { + @include box-shadow(0 2px 8px rgba(0, 0, 0, 0.1)); + + [data-theme='dark'] & { + @include box-shadow(0 2px 8px rgba(255, 255, 255, 0.1)); + } + } + + ul.file-tree { + list-style: none; + margin: 0; + padding: 0; + + .file-tree-item { + position: relative; + + &.file-tree-folder { + > .file-tree-toggle { + cursor: pointer; + font-weight: bold; + + [data-theme='dark'] & { + color: #ddd; + } + + &:hover { + i, + span { + opacity: 0.6; + } + } + } + } + + .file-tree-label { + display: inline-flex; + align-items: center; + position: relative; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + @include border-radius($global-border-radius); + @include transition(all 0.15s ease); + + .file-tree-icon { + width: 1rem; + text-align: center; + flex-shrink: 0; + } + + &.is-highlighted { + color: var(#{$rootPrefix}global-link-hover-color); + } + } + } + + &:not([data-level='0']) { + .file-tree-item { + margin: 0.25rem 0; + padding-left: 0.25rem; + + .file-tree-label::before { + content: ""; + position: absolute; + top: 50%; + left: -0.5rem; + translate: 0 -50%; + width: 0.75rem; + height: 1px; + background-color: var(#{$rootPrefix}secondary); + } + } + } + } + + .file-tree .file-tree { + padding-left: 1.25rem; + position: relative; + + &::before { + content: ""; + position: absolute; + top: -0.25rem; + bottom: 0.625rem; + left: calc(1rem - 0.5px); + width: 1px; + background-color: var(#{$rootPrefix}secondary); + height: var(#{$rootPrefix}file-tree-line-height); + } + } + + .file-tree-item.is-collapsed { + > .file-tree { + display: none; + } + + > .file-tree-label .file-tree-icon::before { + content: '\f07b'; + } + } +} diff --git a/assets/css/_shortcodes/_index.scss b/assets/css/_shortcodes/_index.scss index 2e5cbf4e..942b03d9 100644 --- a/assets/css/_shortcodes/_index.scss +++ b/assets/css/_shortcodes/_index.scss @@ -5,6 +5,7 @@ @import '_cardlink'; @import '_center-quote'; @import '_echarts'; +@import '_file-tree'; @import '_instagram'; @import '_mapbox'; @import '_mermaid'; diff --git a/assets/css/_shortcodes/_tabs.scss b/assets/css/_shortcodes/_tabs.scss index cf274b5e..576c0b64 100644 --- a/assets/css/_shortcodes/_tabs.scss +++ b/assets/css/_shortcodes/_tabs.scss @@ -15,7 +15,7 @@ tab-container { display: flex; gap: 0.75rem; - margin-block: 0.5rem; + margin-block: 0.5rem 1rem; position: relative; // loading before defined @@ -46,6 +46,9 @@ tab-container { &[hidden] { display: none; } + > *:first-child { + margin-top: 0; + } } &::part(tablist-tab-wrapper) { diff --git a/assets/css/_shortcodes/_timeline.scss b/assets/css/_shortcodes/_timeline.scss index b7b5c840..a26f1bc6 100644 --- a/assets/css/_shortcodes/_timeline.scss +++ b/assets/css/_shortcodes/_timeline.scss @@ -1,4 +1,4 @@ -.#{$prefix}timeline { +ul.#{$prefix}timeline { list-style: none; box-sizing: border-box; overflow: auto; diff --git a/assets/css/_variables.scss b/assets/css/_variables.scss index ac10ffa7..0ce53cf9 100644 --- a/assets/css/_variables.scss +++ b/assets/css/_variables.scss @@ -41,6 +41,8 @@ $global-link-hover-color-dark: #fff !default; // Color of the border // TODO refactor border colors +// $global-border-color: #e3e3e3 !default; +// $global-border-color-dark: #434343 !default; $global-border-color: #f0f0f0 !default; $global-border-color-dark: #363636 !default; // ========== Global ========== // diff --git a/assets/js/lib/file-tree.js b/assets/js/lib/file-tree.js new file mode 100644 index 00000000..b581372d --- /dev/null +++ b/assets/js/lib/file-tree.js @@ -0,0 +1,50 @@ +/** + * FileTree class to handle file tree interactions + */ +export default class FileTree { + init(target = document) { + target.querySelectorAll('.file-tree-toggle:not([data-init])').forEach((label) => { + label.addEventListener('click', (e) => { + e.stopPropagation() + const item = label.closest('.file-tree-folder') + const isCollapsed = item.classList.contains('is-collapsed') + item && item.classList.toggle('is-collapsed', !isCollapsed) + + const wrapper = label.closest('.file-tree-wrapper') + this.updateLineHeight(wrapper) + }) + label.dataset.init = 'true' + }) + this.updateLineHeight(target) + } + + updateLineHeight(target = document) { + const uls = target.querySelectorAll('.file-tree .file-tree') + uls.forEach((ul) => { + const parentItem = ul.closest('.file-tree-item.is-collapsed') + if (parentItem) { + ul.style.removeProperty('--fi-file-tree-line-height') + return + } + const items = Array.from(ul.children).filter((el) => el.classList?.contains('file-tree-item')) + if (!items.length) { + ul.style.removeProperty('--fi-file-tree-line-height') + return + } + + const firstLabel = items[0].querySelector('.file-tree-label') + const lastLabel = items[items.length - 1].querySelector('.file-tree-label') + if (!firstLabel || !lastLabel) return + + const firstRect = firstLabel.getBoundingClientRect() + const lastRect = lastLabel.getBoundingClientRect() + + const firstCenterY = firstRect.top + firstRect.height / 2 + const lastCenterY = lastRect.top + lastRect.height / 2 + const offsetY = firstRect.height / 2 + 1/2 + 4 + const height = Math.max(0, lastCenterY - firstCenterY + offsetY) + + ul.style.setProperty('--fi-file-tree-line-height', `${height}px`) + }) + } +} diff --git a/assets/js/theme.js b/assets/js/theme.js index 9d852fa8..de1bdac3 100644 --- a/assets/js/theme.js +++ b/assets/js/theme.js @@ -1,11 +1,14 @@ // TODO use ESLint to check the code style +// 按需加载主题内部库和功能模块 import Util from './util'; +import FileTree from './lib/file-tree.js' class FixIt { constructor() { this.config = window.config; this.isDark = document.documentElement.dataset.theme === 'dark'; this.util = new Util(); + this.fileTree = new FileTree(); this.newScrollTop = this.util.getScrollTop(); this.oldScrollTop = this.newScrollTop; this.scrollEventSet = new Set(); @@ -1122,6 +1125,7 @@ class FixIt { initTabEvents(target = document) { target.addEventListener('tab-container-changed', () => { + this.fileTree.updateLineHeight(target); window.FixItMermaid?.init?.(); }, false); } @@ -1150,6 +1154,7 @@ class FixIt { this.initMathJax(); this.initJsonViewer(); this.initTabEvents(target); + this.fileTree.init(target); window.FixItMermaid?.init?.(); window.FixItAPlayer?.init?.(); } diff --git a/hugo.toml b/hugo.toml index 21c5c182..21cef513 100644 --- a/hugo.toml +++ b/hugo.toml @@ -1160,6 +1160,16 @@ copyable = true sort = false boxed = true +# FixIt 0.4.2 | NEW File tree config +# See https://fixit.lruihao.cn/documentation/content-management/shortcodes/extended/file-tree/ +[params.filetree] +# the expand level of the tree (expand all: -1, collapse all: 0) +level = 1 +# whether to append a trailing "/" to folder names +folderSlash = false +# list of file or folder names to ignore +ignoreList = [] + # FixIt 0.3.12 | NEW Custom partials config # Custom partials must be stored in the /layouts/_partials/ directory. # Depends on open custom blocks https://fixit.lruihao.cn/references/blocks/ @@ -1187,7 +1197,7 @@ postFooterAfter = [] enable = false # Check for updates c4u = false -# # FixIt 0.4.0 | NEW Enable debug mode +# FixIt 0.4.0 | NEW Enable debug mode debug = false # Page config diff --git a/layouts/_markup/render-codeblock-file-tree.html b/layouts/_markup/render-codeblock-file-tree.html new file mode 100644 index 00000000..cc2f844c --- /dev/null +++ b/layouts/_markup/render-codeblock-file-tree.html @@ -0,0 +1,51 @@ +{{- /* + Render hook for file-tree code blocks + Alternative to file-tree shortcode's inner content rendering. + + Supported attributes: + - level: The expand level of the tree (expand all: -1, collapse all: 0, default: 1) + - folderSlash: Whether to append a trailing "/" to folder names (default: false) + - ignoreList: List of file or folder names to ignore, separated by commas + - highlightList: List of file or folder names to highlight, separated by commas + + Content format: YAML/JSON/TOML with the following item structure: + name: string - The name of the file or folder + type: string - Type of item, either "dir" (directory) or "file" + children?: array - (Optional) Array of child items, only valid for directories + + Example usage: + ```file-tree {level=2 folderSlash=true} + - name: my-project + type: dir + children: + - name: src + type: dir + - name: package.json + type: file + ``` +*/ -}} + +{{- $pageConfig := dict | merge .Page.Params.filetree | merge .Page.Site.Params.filetree -}} +{{- $config := .Attributes | merge $pageConfig -}} +{{- $level := cond (isset $config "level") ($config.level | int) 1 -}} +{{- $folderSlash := $config.folderslash | default false -}} +{{- $ignoreList := union (apply (split (.Attributes.ignorelist | default "") ",") "trim" "." " ") $pageConfig.ignorelist -}} +{{- $highlightList := apply (split (.Attributes.highlightlist | default "") ",") "trim" "." " " -}} + +{{- $content := strings.TrimSpace .Inner | default "" -}} +{{- $treeData := slice -}} +{{- with $content -}} + {{- $treeData = . | unmarshal -}} + {{- if reflect.IsMap $treeData -}} + {{- $treeData = $treeData.filetree -}} + {{- end -}} +{{- end -}} + +{{- $options := dict + "items" $treeData + "level" $level + "folderSlash" $folderSlash + "ignoreList" $ignoreList + "highlightList" $highlightList +-}} +{{- partial "plugin/file-tree.html" $options -}} diff --git a/layouts/_partials/devtools/template-call-stack.html b/layouts/_partials/devtools/template-call-stack.html index 6976fe0c..57d3bc24 100644 --- a/layouts/_partials/devtools/template-call-stack.html +++ b/layouts/_partials/devtools/template-call-stack.html @@ -2,7 +2,9 @@ Render template call stack for debugging purpose. Call the partial from any template: {{- partial "devtools/template-call-stack.html" . -}} */ -}} -{{- if .Site.Params.dev.debug | and hugo.IsServer -}} +{{- $debug := (site.Store.Get "devOpts").debug -}} + +{{- if $debug | and hugo.IsServer -}} {{- with templates.Current -}}