这里是卡片内容。
Obsidian Plume 组件示例
在 Obsidian 阅读模式打开本页,并启用 Obsidian Plume 插件。内容与仓库根目录 示例.md 对齐,并按 Obsidian 环境做了路径与图标说明。
源码在哪? 本文件即 Markdown 原文。在线预览站每节上方有 Markdown 源码 面板;Obsidian 中请用 编辑模式 查看
:::围栏块。
路径:本文件位于
examples/。@[code-tree]使用..指向插件根目录;库内其它路径请改成你的文件夹名。
Markdown 源码
### 默认标题样式
::: note
这是一个注释框
:::
::: info
这是一个信息框
:::
::: tip
这是一个提示框
:::
::: warning
这是一个警告框
:::
::: caution
这是一个危险警告框
:::
::: details
这是一个详情折叠框
:::
### 自定义标题
::: caution STOP
危险区域,请勿继续
:::
::: details 点我查看代码
```js
console.log('Hello, VitePress!')
```
:::1. 提示容器 prompt
默认标题样式
NOTE
这是一个注释框
INFO
这是一个信息框
TIP
这是一个提示框
WARNING
这是一个警告框
CAUTION
这是一个危险警告框
DETAILS
这是一个详情折叠框
自定义标题
STOP
危险区域,请勿继续
点我查看代码
console.log('Hello, VitePress!')Markdown 源码
:::: steps
1. 步骤 1
```ts
console.log('Hello World!')
```
2. 步骤 2
这里是步骤 2 的相关内容
3. 步骤 3
::: tip
提示容器
:::
4. 结束
::::2. 步骤 ::: steps
步骤 1
console.log('Hello World!')步骤 2
这里是步骤 2 的相关内容
步骤 3
TIP
提示容器
结束
Markdown 源码
::: file-tree
- docs
- .vuepress
- ++ config.ts
- -- page1.md
- README.md
- theme # 一个 **主题** 目录
- client
- components
- **Navbar.vue**
- composables
- useNavbar.ts
- styles
- navbar.css
- config.ts
- node/
- package.json
- pnpm-lock.yaml
- .gitignore
- README.md
- …
:::
> `++` / `--` 表示聚焦 / 淡化;`…` 为省略号节点。可选 `icon="colored"` / `icon="simple"`。3. 文件树 ::: file-tree
config.ts
page1.md
README.md
Navbar.vue
useNavbar.ts
navbar.css
config.ts
node
…
package.json
pnpm-lock.yaml
.gitignore
README.md
…
++/--表示聚焦 / 淡化;…为省略号节点。可选icon="colored"/icon="simple"。
Markdown 源码
### code-tree 容器
::: code-tree title="Vue App" height="400px" entry="src/main.ts"
```vue title="src/components/HelloWorld.vue"
<template>
<div class="hello">
<h1>Hello World</h1>
</div>
</template>
```
```vue title="src/App.vue"
<template>
<div id="app">
<h3>vuepress-theme-plume</h3>
<HelloWorld />
</div>
</template>
```
```ts title="src/main.ts"
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
```
```json title="package.json"
{
"name": "Vue App",
"scripts": {
"dev": "vite"
}
}
```
:::4. 代码树 ::: code-tree
code-tree 容器
Vue App
HelloWorld.vue
App.vue
main.ts
package.json
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')<template>
<div class="hello">
<h1>Hello World</h1>
</div>
</template><template>
<div id="app">
<h3>vuepress-theme-plume</h3>
<HelloWorld />
</div>
</template>import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app'){
"name": "Vue App",
"scripts": {
"dev": "vite"
}
}Markdown 源码
### 简单配置
@[code-tree](../src)
### 添加配置
@[code-tree title="插件源码" height="800px" entry="parser.ts"](../src)
> 路径相对于本文件;`build:demo` 会跳过超大文件与 `main.js` 等,避免静态站构建占满内存。5. 目录嵌入 @[code-tree]
简单配置
plume-complex-test.md
vuepressFileIcons.ts
icons.ts
plume-markdown.ts
offlineIconify.ts
parser.test.ts
parser.ts
code-fence-titles.ts
preview-pipeline.ts
preview-sync.ts
render.ts
badge-transform.ts
block-registry.ts
collapse.ts
code-fence.ts
context.ts
icon-transform.test.ts
icon-transform.ts
iconify-online.ts
index.ts
inline.ts
markdown-transforms.ts
pipeline.ts
tab-store.ts
tabbed-container.ts
types.ts
hash.ts
# Parser fixture
:::: card-grid cols="2"
::: card title="Backend" icon="server"
::: collapse expand
- API
::: code-tabs
@tab GET
```ts
export const method = "GET"@tab POST
export const method = "POST":::
Worker
Queue details.
:::
:::
::: card title="Frontend" icon="layout"
Client details.
:::
::::
# Parser fixture
:::: card-grid cols="2"
::: card title="Backend" icon="server"
::: collapse expand
- API
::: code-tabs
@tab GET
```ts
export const method = "GET"@tab POST
export const method = "POST":::
Worker
Queue details.
:::
:::
::: card title="Frontend" icon="layout"
Client details.
:::
::::
/* AUTO-GENERATED FILE. DO NOT EDIT. */
/* Generated by scripts/generate-vuepress-file-icons.mjs */
export interface VuepressFileIconRules {
named: Record<string, string>;
folders: Record<string, string>;
files: Record<string, string>;
extensions: Record<string, string>;
partials: Record<string, string>;
}
export const VUEPRESS_FILE_ICON_RULES: VuepressFileIconRules = {
named: {
"pnpm": "vscode-icons:file-type-light-pnpm",
"npm": "logos:npm-icon",
"yarn": "vscode-icons:file-type-yarn",
"bun": "vscode-icons:file-type-bun",
"deno": "vscode-icons:file-type-light-deno",
"rollup": "vscode-icons:file-type-rollup",
"webpack": "vscode-icons:file-type-webpack",
"vite": "vscode-icons:file-type-vite",
"esbuild": "vscode-icons:file-type-esbuild",
"vue": "vscode-icons:file-type-vue",
"svelte": "vscode-icons:file-type-svelte",
"sveltekit": "vscode-icons:file-type-svelte",
"angular": "vscode-icons:file-type-angular",
"react": "vscode-icons:file-type-reactjs",
"next": "vscode-icons:file-type-light-next",
"nextjs": "vscode-icons:file-type-light-next",
"nuxt": "vscode-icons:file-type-nuxt",
"nuxtjs": "vscode-icons:file-type-nuxt",
"solid": "logos:solidjs-icon",
"solidjs": "logos:solidjs-icon",
"astro": "vscode-icons:file-type-light-astro",
"vitest": "vscode-icons:file-type-vitest",
"playwright": "vscode-icons:file-type-playwright",
"jest": "vscode-icons:file-type-jest",
"cypress": "vscode-icons:file-type-cypress",
"docker": "vscode-icons:file-type-docker",
"html": "vscode-icons:file-type-html",
"javascript": "vscode-icons:file-type-js-official",
"js": "vscode-icons:file-type-js-official",
"typescript": "vscode-icons:file-type-typescript-official",
"ts": "vscode-icons:file-type-typescript-official",
"css": "vscode-icons:file-type-css",
"less": "vscode-icons:file-type-less",
"scss": "vscode-icons:file-type-scss",
"sass": "vscode-icons:file-type-sass",
"stylus": "vscode-icons:file-type-light-stylus",
"postcss": "vscode-icons:file-type-postcss",
"sh": "vscode-icons:file-type-shell",
"shell": "vscode-icons:file-type-shell",
"bash": "vscode-icons:file-type-shell",
"java": "vscode-icons:file-type-java",
"php": "vscode-icons:file-type-php3",
"c": "vscode-icons:file-type-c",
"python": "vscode-icons:file-type-python",
"kotlin": "vscode-icons:file-type-kotlin",
"go": "vscode-icons:file-type-go-gopher",
"golang": "vscode-icons:file-type-go-gopher",
"rust": "vscode-icons:file-type-rust",
"zig": "vscode-icons:file-type-zig",
"swift": "vscode-icons:file-type-swift",
"c#": "vscode-icons:file-type-csharp",
"csharp": "vscode-icons:file-type-csharp",
"c++": "vscode-icons:file-type-cpp",
"ruby": "vscode-icons:file-type-ruby",
"makefile": "vscode-icons:file-type-makefile",
"object-c": "vscode-icons:file-type-objectivec",
"sql": "vscode-icons:file-type-sql",
"mysql": "vscode-icons:file-type-mysql",
"pgsql": "vscode-icons:file-type-pgsql",
"postgresql": "vscode-icons:file-type-pgsql",
"xml": "vscode-icons:file-type-xml",
"wasm": "vscode-icons:file-type-wasm",
"webassembly": "vscode-icons:file-type-wasm",
"toml": "vscode-icons:file-type-light-toml",
"yaml": "vscode-icons:file-type-light-yaml",
},
folders: {
"default": "vscode-icons:default-folder",
"src": "vscode-icons:folder-type-src",
"srcs": "vscode-icons:folder-type-src",
"source": "vscode-icons:folder-type-src",
"sources": "vscode-icons:folder-type-src",
"code": "vscode-icons:folder-type-src",
"tauri-src": "vscode-icons:folder-type-tauri",
"dist": "vscode-icons:folder-type-dist",
"out": "vscode-icons:folder-type-dist",
"output": "vscode-icons:folder-type-dist",
"release": "vscode-icons:folder-type-dist",
"bin": "vscode-icons:folder-type-dist",
"distribution": "vscode-icons:folder-type-dist",
"docs": "vscode-icons:folder-type-docs",
"doc": "vscode-icons:folder-type-docs",
"document": "vscode-icons:folder-type-docs",
"documents": "vscode-icons:folder-type-docs",
"documentation": "vscode-icons:folder-type-docs",
"post": "vscode-icons:folder-type-docs",
"posts": "vscode-icons:folder-type-docs",
"article": "vscode-icons:folder-type-docs",
"articles": "vscode-icons:folder-type-docs",
"scripts": "vscode-icons:folder-type-script",
"script": "vscode-icons:folder-type-script",
"node_modules": "vscode-icons:folder-type-light-node",
"cli": "vscode-icons:folder-type-cli",
"template": "vscode-icons:folder-type-template",
"templates": "vscode-icons:folder-type-template",
"theme": "vscode-icons:folder-type-theme",
"themes": "vscode-icons:folder-type-theme",
"color": "vscode-icons:folder-type-theme",
"colors": "vscode-icons:folder-type-theme",
"design": "vscode-icons:folder-type-theme",
"designs": "vscode-icons:folder-type-theme",
"packages": "vscode-icons:folder-type-package",
"package": "vscode-icons:folder-type-package",
"pkg": "vscode-icons:folder-type-package",
"pkgs": "vscode-icons:folder-type-package",
"shared": "vscode-icons:folder-type-shared",
"utils": "vscode-icons:folder-type-tools",
"util": "vscode-icons:folder-type-tools",
"utility": "vscode-icons:folder-type-tools",
"utilities": "vscode-icons:folder-type-tools",
"helper": "vscode-icons:folder-type-helper",
"helpers": "vscode-icons:folder-type-helper",
"tools": "vscode-icons:folder-type-tools",
"toolkit": "vscode-icons:folder-type-tools",
"toolkits": "vscode-icons:folder-type-tools",
"tooling": "vscode-icons:folder-type-tools",
"devtools": "vscode-icons:folder-type-tools",
"component": "vscode-icons:folder-type-component",
"components": "vscode-icons:folder-type-component",
"widget": "vscode-icons:folder-type-component",
"widgets": "vscode-icons:folder-type-component",
"fragments": "vscode-icons:folder-type-component",
"hooks": "vscode-icons:folder-type-hook",
"composables": "vscode-icons:folder-type-hook",
"public": "vscode-icons:folder-type-public",
"www": "vscode-icons:folder-type-public",
"web": "vscode-icons:folder-type-public",
"wwwroot": "vscode-icons:folder-type-public",
"website": "vscode-icons:folder-type-public",
"site": "vscode-icons:folder-type-public",
"browser": "vscode-icons:folder-type-public",
"browsers": "vscode-icons:folder-type-public",
"fonts": "vscode-icons:folder-type-fonts",
"font": "vscode-icons:folder-type-fonts",
"images": "vscode-icons:folder-type-images",
"image": "vscode-icons:folder-type-images",
"imgs": "vscode-icons:folder-type-images",
"img": "vscode-icons:folder-type-images",
"icon": "vscode-icons:folder-type-images",
"icons": "vscode-icons:folder-type-images",
"ico": "vscode-icons:folder-type-images",
"icos": "vscode-icons:folder-type-images",
"figure": "vscode-icons:folder-type-images",
"figures": "vscode-icons:folder-type-images",
"fig": "vscode-icons:folder-type-images",
"figs": "vscode-icons:folder-type-images",
"screenshot": "vscode-icons:folder-type-images",
"screenshots": "vscode-icons:folder-type-images",
"screengrab": "vscode-icons:folder-type-images",
"screengrabs": "vscode-icons:folder-type-images",
"pic": "vscode-icons:folder-type-images",
"pics": "vscode-icons:folder-type-images",
"picture": "vscode-icons:folder-type-images",
"pictures": "vscode-icons:folder-type-images",
"photo": "vscode-icons:folder-type-images",
"photos": "vscode-icons:folder-type-images",
"photograph": "vscode-icons:folder-type-images",
"photographs": "vscode-icons:folder-type-images",
"asset": "vscode-icons:folder-type-asset",
"assets": "vscode-icons:folder-type-asset",
"resource": "vscode-icons:folder-type-asset",
"resources": "vscode-icons:folder-type-asset",
"res": "vscode-icons:folder-type-asset",
"static": "vscode-icons:folder-type-asset",
"report": "vscode-icons:folder-type-asset",
"reports": "vscode-icons:folder-type-asset",
"apis": "vscode-icons:folder-type-api",
"api": "vscode-icons:folder-type-api",
"restapi": "vscode-icons:folder-type-api",
"style": "vscode-icons:folder-type-style",
"styles": "vscode-icons:folder-type-style",
"stylesheet": "vscode-icons:folder-type-style",
"stylesheets": "vscode-icons:folder-type-style",
"css": "vscode-icons:folder-type-css",
"scss": "vscode-icons:folder-type-light-sass",
"sass": "vscode-icons:folder-type-light-sass",
"less": "vscode-icons:folder-type-less",
"plugin": "vscode-icons:folder-type-plugin",
"plugins": "vscode-icons:folder-type-plugin",
"typings": "vscode-icons:folder-type-typings",
"types": "vscode-icons:folder-type-typings",
"mock": "vscode-icons:folder-type-mock",
"i18n": "vscode-icons:folder-type-locale",
"locales": "vscode-icons:folder-type-locale",
"locale": "vscode-icons:folder-type-locale",
"lang": "vscode-icons:folder-type-locale",
"langs": "vscode-icons:folder-type-locale",
"language": "vscode-icons:folder-type-locale",
"languages": "vscode-icons:folder-type-locale",
"l10n": "vscode-icons:folder-type-locale",
"localization": "vscode-icons:folder-type-locale",
"translation": "vscode-icons:folder-type-locale",
"translate": "vscode-icons:folder-type-locale",
"translations": "vscode-icons:folder-type-locale",
"tx": "vscode-icons:folder-type-locale",
"config": "vscode-icons:folder-type-config",
"configs": "vscode-icons:folder-type-config",
".config": "vscode-icons:folder-type-config",
".configs": "vscode-icons:folder-type-config",
"cfg": "vscode-icons:folder-type-config",
"cfgs": "vscode-icons:folder-type-config",
"conf": "vscode-icons:folder-type-config",
"confs": "vscode-icons:folder-type-config",
"configuration": "vscode-icons:folder-type-config",
"configurations": "vscode-icons:folder-type-config",
"setting": "vscode-icons:folder-type-config",
"settings": "vscode-icons:folder-type-config",
"option": "vscode-icons:folder-type-config",
"options": "vscode-icons:folder-type-config",
"controller": "vscode-icons:folder-type-controller",
"controllers": "vscode-icons:folder-type-controller",
"model": "vscode-icons:folder-type-model",
"models": "vscode-icons:folder-type-model",
"service": "vscode-icons:folder-type-services",
"services": "vscode-icons:folder-type-services",
"view": "vscode-icons:folder-type-view",
"views": "vscode-icons:folder-type-view",
"page": "vscode-icons:folder-type-view",
"pages": "vscode-icons:folder-type-view",
"html": "vscode-icons:folder-type-view",
"app": "vscode-icons:folder-type-app",
"apps": "vscode-icons:folder-type-app",
"client": "vscode-icons:folder-type-client",
"clients": "vscode-icons:folder-type-client",
"frontend": "vscode-icons:folder-type-client",
"frontends": "vscode-icons:folder-type-client",
"server": "vscode-icons:folder-type-server",
"servers": "vscode-icons:folder-type-server",
"backend": "vscode-icons:folder-type-server",
"backends": "vscode-icons:folder-type-server",
"db": "vscode-icons:folder-type-db",
"database": "vscode-icons:folder-type-db",
"databases": "vscode-icons:folder-type-db",
"data": "vscode-icons:folder-type-db",
"sql": "vscode-icons:folder-type-db",
"e2e": "vscode-icons:folder-type-e2e",
"cypress": "vscode-icons:folder-type-light-cypress",
"test": "vscode-icons:folder-type-test",
"tests": "vscode-icons:folder-type-test",
"testing": "vscode-icons:folder-type-test",
"snapshots": "vscode-icons:folder-type-test",
"spec": "vscode-icons:folder-type-test",
"specs": "vscode-icons:folder-type-test",
"lib": "vscode-icons:folder-type-library",
"libs": "vscode-icons:folder-type-library",
"library": "vscode-icons:folder-type-library",
"libraries": "vscode-icons:folder-type-library",
"vendor": "vscode-icons:folder-type-library",
"vendors": "vscode-icons:folder-type-library",
"third-party": "vscode-icons:folder-type-library",
"lib64": "vscode-icons:folder-type-library",
"include": "vscode-icons:folder-type-include",
"inc": "vscode-icons:folder-type-include",
"includes": "vscode-icons:folder-type-include",
"partial": "vscode-icons:folder-type-include",
"partials": "vscode-icons:folder-type-include",
"inc64": "vscode-icons:folder-type-include",
"temp": "vscode-icons:folder-type-temp",
"tmp": "vscode-icons:folder-type-temp",
"cache": "vscode-icons:folder-type-temp",
"cached": "vscode-icons:folder-type-temp",
".temp": "vscode-icons:folder-type-temp",
".cache": "vscode-icons:folder-type-temp",
"log": "vscode-icons:folder-type-log",
"logs": "vscode-icons:folder-type-log",
"logging": "vscode-icons:folder-type-log",
".svelte-kit": "vscode-icons:folder-type-svelte",
".git": "vscode-icons:folder-type-git",
".github": "vscode-icons:folder-type-github",
".gitlab": "vscode-icons:folder-type-gitlab",
".vscode": "vscode-icons:folder-type-vscode",
".husky": "vscode-icons:folder-type-husky",
".idea": "vscode-icons:folder-type-idea",
".changesets": "vscode-icons:folder-type-changesets",
".vercel": "vscode-icons:folder-type-vercel",
".netlify": "vscode-icons:folder-type-netlify",
".claude": "vscode-icons:folder-type-claude",
".cursor": "vscode-icons:folder-type-cursor",
".gemini": "vscode-icons:folder-type-gemini",
".windsurf": "vscode-icons:folder-type-windsurf",
},
files: {
"package.json": "vscode-icons:file-type-node",
"pnpm-debug.log": "vscode-icons:file-type-light-pnpm",
"pnpm-lock.yaml": "vscode-icons:file-type-light-pnpm",
"pnpm-workspace.yaml": "vscode-icons:file-type-light-pnpm",
".pnpmfile.cjs": "vscode-icons:file-type-light-pnpm",
"pnpmfile.js": "vscode-icons:file-type-light-pnpm",
"biome.json": "vscode-icons:file-type-biome",
"bun.lockb": "vscode-icons:file-type-bun",
"commit_editmsg": "vscode-icons:file-type-git",
"merge_msg": "vscode-icons:file-type-git",
"karma.conf.js": "vscode-icons:file-type-karma",
"karma.conf.cjs": "vscode-icons:file-type-karma",
"karma.conf.mjs": "vscode-icons:file-type-karma",
"karma.conf.coffee": "vscode-icons:file-type-karma",
"readme.md": "flat-color-icons:info",
"readme.txt": "flat-color-icons:info",
"readme": "flat-color-icons:info",
"changelog.md": "catppuccin:changelog",
"changelog.txt": "catppuccin:changelog",
"changelog": "catppuccin:changelog",
"changes.md": "catppuccin:changelog",
"changes.txt": "catppuccin:changelog",
"changes": "catppuccin:changelog",
"version.md": "catppuccin:changelog",
"version.txt": "catppuccin:changelog",
"version": "catppuccin:changelog",
"mvnw": "vscode-icons:file-type-maven",
"pom.xml": "vscode-icons:file-type-maven",
"tsconfig.json": "vscode-icons:file-type-tsconfig",
"swagger.json": "vscode-icons:file-type-swagger",
"swagger.yml": "vscode-icons:file-type-swagger",
"swagger.yaml": "vscode-icons:file-type-swagger",
"mime.types": "vscode-icons:file-type-light-config",
"jenkinsfile": "vscode-icons:file-type-jenkins",
"babel.config.js": "vscode-icons:file-type-babel2",
"babel.config.json": "vscode-icons:file-type-babel2",
"babel.config.cjs": "vscode-icons:file-type-babel2",
"build": "vscode-icons:file-type-bazel",
"build.bazel": "vscode-icons:file-type-bazel",
"workspace": "vscode-icons:file-type-bazel",
"workspace.bazel": "vscode-icons:file-type-bazel",
"bower.json": "vscode-icons:file-type-bower2",
"eslint.config.js": "vscode-icons:file-type-eslint",
"eslint.config.ts": "vscode-icons:file-type-eslint",
"firebase.json": "vscode-icons:file-type-firebase",
"geckodriver": "openmoji:firefox",
"gruntfile.js": "vscode-icons:file-type-grunt",
"gruntfile.babel.js": "vscode-icons:file-type-grunt",
"gruntfile.coffee": "vscode-icons:file-type-grunt",
"ionic.config.json": "vscode-icons:file-type-ionic",
"ionic.project": "vscode-icons:file-type-ionic",
"platformio.ini": "vscode-icons:file-type-platformio",
"rollup.config.js": "vscode-icons:file-type-rollup",
"sass-lint.yml": "vscode-icons:file-type-sass",
"stylelint.config.js": "vscode-icons:file-type-light-stylelint",
"stylelint.config.cjs": "vscode-icons:file-type-light-stylelint",
"stylelint.config.mjs": "vscode-icons:file-type-light-stylelint",
"yarn.clean": "vscode-icons:file-type-yarn",
"yarn.lock": "vscode-icons:file-type-yarn",
"webpack.config.js": "vscode-icons:file-type-webpack",
"webpack.config.cjs": "vscode-icons:file-type-webpack",
"webpack.config.mjs": "vscode-icons:file-type-webpack",
"webpack.config.ts": "vscode-icons:file-type-webpack",
"webpack.config.build.js": "vscode-icons:file-type-webpack",
"webpack.config.build.cjs": "vscode-icons:file-type-webpack",
"webpack.config.build.mjs": "vscode-icons:file-type-webpack",
"webpack.config.build.ts": "vscode-icons:file-type-webpack",
"webpack.common.js": "vscode-icons:file-type-webpack",
"webpack.common.cjs": "vscode-icons:file-type-webpack",
"webpack.common.mjs": "vscode-icons:file-type-webpack",
"webpack.common.ts": "vscode-icons:file-type-webpack",
"webpack.dev.js": "vscode-icons:file-type-webpack",
"webpack.dev.cjs": "vscode-icons:file-type-webpack",
"webpack.dev.mjs": "vscode-icons:file-type-webpack",
"webpack.dev.ts": "vscode-icons:file-type-webpack",
"webpack.prod.js": "vscode-icons:file-type-webpack",
"webpack.prod.cjs": "vscode-icons:file-type-webpack",
"webpack.prod.mjs": "vscode-icons:file-type-webpack",
"webpack.prod.ts": "vscode-icons:file-type-webpack",
"vite.config.js": "vscode-icons:file-type-vite",
"vite.config.ts": "vscode-icons:file-type-vite",
"vite.config.mjs": "vscode-icons:file-type-vite",
"vite.config.cjs": "vscode-icons:file-type-vite",
"vitest.config.ts": "vscode-icons:file-type-vitest",
"vitest.config.js": "vscode-icons:file-type-vitest",
"vitest.config.mjs": "vscode-icons:file-type-vitest",
"vitest.config.cjs": "vscode-icons:file-type-vitest",
"turbo.json": "vscode-icons:file-type-light-turbo",
"vercel.json": "vscode-icons:file-type-light-vercel",
"netlify.toml": "vscode-icons:file-type-light-netlify",
"cargo.toml": "vscode-icons:file-type-cargo",
"cargo.lock": "vscode-icons:file-type-cargo",
"npm-debug.log": "vscode-icons:file-type-npm",
"components.json": "vscode-icons:file-type-light-shadcn",
".postcssrc": "vscode-icons:file-type-postcssconfig",
".postcssrc.json": "vscode-icons:file-type-postcssconfig",
".postcssrc.yaml": "vscode-icons:file-type-postcssconfig",
".postcssrc.yml": "vscode-icons:file-type-postcssconfig",
".postcssrc.ts": "vscode-icons:file-type-postcssconfig",
".postcssrc.cts": "vscode-icons:file-type-postcssconfig",
".postcssrc.mts": "vscode-icons:file-type-postcssconfig",
".postcssrc.js": "vscode-icons:file-type-postcssconfig",
".postcssrc.cjs": "vscode-icons:file-type-postcssconfig",
".postcssrc.mjs": "vscode-icons:file-type-postcssconfig",
"postcss.config.ts": "vscode-icons:file-type-postcssconfig",
"postcss.config.cts": "vscode-icons:file-type-postcssconfig",
"postcss.config.mts": "vscode-icons:file-type-postcssconfig",
"postcss.config.js": "vscode-icons:file-type-postcssconfig",
"postcss.config.cjs": "vscode-icons:file-type-postcssconfig",
"postcss.config.mjs": "vscode-icons:file-type-postcssconfig",
"uno.config.js": "vscode-icons:file-type-unocss",
"uno.config.mjs": "vscode-icons:file-type-unocss",
"uno.config.ts": "vscode-icons:file-type-unocss",
"unocss.config.js": "vscode-icons:file-type-unocss",
"unocss.config.mjs": "vscode-icons:file-type-unocss",
"unocss.config.ts": "vscode-icons:file-type-unocss",
"rolldown.config.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.ts": "vscode-icons:file-type-light-rolldown",
"tsdown.config.js": "vscode-icons:file-type-tsdown",
"tsdown.config.cjs": "vscode-icons:file-type-tsdown",
"tsdown.config.mjs": "vscode-icons:file-type-tsdown",
"tsdown.config.ts": "vscode-icons:file-type-tsdown",
"tsdown.config.json": "vscode-icons:file-type-tsdown",
".oxlintignore": "vscode-icons:file-type-oxc",
".oxlintrc.json": "vscode-icons:file-type-oxc",
".oxlintrc.jsonc": "vscode-icons:file-type-oxc",
".oxfmtrc.json": "vscode-icons:file-type-oxc",
".oxfmtrc.jsonc": "vscode-icons:file-type-oxc",
"agents.md": "vscode-icons:file-type-agents",
"claude.md": "vscode-icons:file-type-claude",
"copilot-instructions.md": "vscode-icons:file-type-copilot",
"github-copilot.xml": "vscode-icons:file-type-copilot",
"instructions.md": "vscode-icons:file-type-copilot",
},
extensions: {
".astro": "vscode-icons:file-type-light-astro",
".mdx": "vscode-icons:file-type-light-mdx",
".cls": "vscode-icons:file-type-apex",
".apex": "vscode-icons:file-type-apex",
".asm": "vscode-icons:file-type-assembly",
".s": "vscode-icons:file-type-assembly",
".bicep": "vscode-icons:file-type-bicep",
".bzl": "vscode-icons:file-type-bazel",
".bazel": "vscode-icons:file-type-bazel",
".build": "vscode-icons:file-type-bazel",
".workspace": "vscode-icons:file-type-bazel",
".bazelignore": "vscode-icons:file-type-bazel",
".bazelversion": "vscode-icons:file-type-bazel",
".c": "vscode-icons:file-type-c",
".h": "vscode-icons:file-type-c",
".m": "vscode-icons:file-type-c",
".cs": "vscode-icons:file-type-csharp",
".cshtml": "vscode-icons:file-type-html",
".aspx": "vscode-icons:file-type-aspx",
".ascx": "vscode-icons:file-type-aspx",
".asax": "vscode-icons:file-type-aspx",
".master": "vscode-icons:file-type-html",
".cc": "vscode-icons:file-type-cpp",
".cpp": "vscode-icons:file-type-cpp",
".cxx": "vscode-icons:file-type-cpp",
".c++": "vscode-icons:file-type-cpp",
".hh": "vscode-icons:file-type-cppheader",
".hpp": "vscode-icons:file-type-cppheader",
".hxx": "vscode-icons:file-type-cppheader",
".h++": "vscode-icons:file-type-cppheader",
".mm": "vscode-icons:file-type-cpp",
".clj": "vscode-icons:file-type-clojure",
".cljs": "vscode-icons:file-type-clojure",
".cljc": "vscode-icons:file-type-clojure",
".edn": "vscode-icons:file-type-clojure",
".cfc": "vscode-icons:file-type-cfc",
".cfm": "vscode-icons:file-type-cfm",
".coffee": "vscode-icons:file-type-coffeescript",
".litcoffee": "vscode-icons:file-type-coffeescript",
".config": "vscode-icons:file-type-config",
".cfg": "vscode-icons:file-type-config",
".conf": "vscode-icons:file-type-config",
".cr": "vscode-icons:file-type-light-crystal",
".dll": "vscode-icons:file-type-binary",
".ecr": "vscode-icons:file-type-light-crystal",
".slang": "vscode-icons:file-type-slang",
".cson": "vscode-icons:file-type-json",
".css": "vscode-icons:file-type-css",
".css.map": "vscode-icons:file-type-cssmap",
".sss": "vscode-icons:file-type-sss",
".csv": "vscode-icons:file-type-excel",
".xls": "vscode-icons:file-type-excel2",
".xlsx": "vscode-icons:file-type-excel2",
".cu": "vscode-icons:file-type-cuda",
".cuh": "vscode-icons:file-type-cuda",
".hu": "vscode-icons:file-type-cuda",
".cake": "vscode-icons:file-type-cake",
".ctp": "vscode-icons:file-type-cakephp",
".d": "vscode-icons:file-type-dependencies",
".doc": "vscode-icons:file-type-word2",
".docx": "vscode-icons:file-type-word2",
".ejs": "vscode-icons:file-type-ejs",
".ex": "vscode-icons:file-type-elixir",
".exs": "vscode-icons:file-type-elixir",
".elm": "vscode-icons:file-type-elm",
".ico": "vscode-icons:file-type-favicon",
".fs": "vscode-icons:file-type-fsharp",
".fsx": "vscode-icons:file-type-fsharp",
".gitignore": "vscode-icons:file-type-git",
".gitconfig": "vscode-icons:file-type-git",
".gitkeep": "vscode-icons:file-type-git",
".gitattributes": "vscode-icons:file-type-git",
".gitmodules": "vscode-icons:file-type-git",
".go": "vscode-icons:file-type-go",
".slide": "vscode-icons:file-type-go",
".article": "vscode-icons:file-type-go",
".gd": "vscode-icons:file-type-godot",
".godot": "vscode-icons:file-type-godot",
".tres": "vscode-icons:file-type-godot",
".tscn": "vscode-icons:file-type-godot",
".gradle": "vscode-icons:file-type-light-gradle",
".groovy": "vscode-icons:file-type-groovy",
".gsp": "vscode-icons:file-type-groovy",
".gql": "vscode-icons:file-type-graphql",
".graphql": "vscode-icons:file-type-graphql",
".graphqls": "vscode-icons:file-type-graphql",
".hack": "logos:hack",
".haml": "vscode-icons:file-type-haml",
".handlebars": "vscode-icons:file-type-handlebars",
".hbs": "vscode-icons:file-type-handlebars",
".hjs": "vscode-icons:file-type-handlebars",
".hs": "vscode-icons:file-type-haskell",
".lhs": "vscode-icons:file-type-haskell",
".hx": "vscode-icons:file-type-haxe",
".hxs": "vscode-icons:file-type-haxe",
".hxp": "vscode-icons:file-type-haxe",
".hxml": "vscode-icons:file-type-haxe",
".html": "vscode-icons:file-type-html",
".jade": "file-icons:jade",
".java": "vscode-icons:file-type-java",
".class": "vscode-icons:file-type-java",
".classpath": "vscode-icons:file-type-java",
".properties": "vscode-icons:file-type-java",
".js": "vscode-icons:file-type-js",
".js.map": "vscode-icons:file-type-jsmap",
".cjs": "vscode-icons:file-type-js",
".cjs.map": "vscode-icons:file-type-jsmap",
".mjs": "vscode-icons:file-type-js",
".mjs.map": "vscode-icons:file-type-jsmap",
".spec.js": "vscode-icons:file-type-light-testjs",
".spec.cjs": "vscode-icons:file-type-light-testjs",
".spec.mjs": "vscode-icons:file-type-light-testjs",
".test.js": "vscode-icons:file-type-light-testjs",
".test.cjs": "vscode-icons:file-type-light-testjs",
".test.mjs": "vscode-icons:file-type-light-testjs",
".es": "vscode-icons:file-type-js",
".es5": "vscode-icons:file-type-js",
".es6": "vscode-icons:file-type-js",
".es7": "vscode-icons:file-type-js",
".jinja": "vscode-icons:file-type-jinja",
".jinja2": "vscode-icons:file-type-jinja",
".json": "vscode-icons:file-type-json",
".jl": "vscode-icons:file-type-julia",
".kt": "vscode-icons:file-type-kotlin",
".kts": "vscode-icons:file-type-kotlin",
".dart": "vscode-icons:file-type-dartlang",
".less": "vscode-icons:file-type-less",
".liquid": "vscode-icons:file-type-liquid",
".ls": "vscode-icons:file-type-livescript",
".lua": "vscode-icons:file-type-lua",
".markdown": "vscode-icons:file-type-markdown",
".md": "vscode-icons:file-type-markdown",
".mustache": "vscode-icons:file-type-light-mustache",
".stache": "vscode-icons:file-type-light-mustache",
".nim": "vscode-icons:file-type-light-nim",
".nims": "vscode-icons:file-type-light-nim",
".github-issues": "mdi:github",
".ipynb": "vscode-icons:file-type-jupyter",
".njk": "vscode-icons:file-type-nunjucks",
".nunjucks": "vscode-icons:file-type-nunjucks",
".nunjs": "vscode-icons:file-type-nunjucks",
".nunj": "vscode-icons:file-type-nunjucks",
".njs": "vscode-icons:file-type-nunjucks",
".nj": "vscode-icons:file-type-nunjucks",
".npm-debug.log": "vscode-icons:file-type-npm",
".npmignore": "catppuccin:npm-ignore",
".npmrc": "vscode-icons:file-type-npm",
".ml": "vscode-icons:file-type-ocaml",
".mli": "vscode-icons:file-type-ocaml",
".cmx": "vscode-icons:file-type-ocaml",
".cmxa": "vscode-icons:file-type-ocaml",
".pl": "vscode-icons:file-type-perl",
".php": "vscode-icons:file-type-php",
".php.inc": "vscode-icons:file-type-php",
".pipeline": "vscode-icons:file-type-pipeline",
".pddl": "vscode-icons:file-type-pddl",
".plan": "vscode-icons:file-type-pddl-plan",
".happenings": "vscode-icons:file-type-pddl-happenings",
".ps1": "vscode-icons:file-type-powershell",
".psd1": "vscode-icons:file-type-powershell",
".psm1": "vscode-icons:file-type-powershell",
".prisma": "vscode-icons:file-type-light-prisma",
".pug": "vscode-icons:file-type-pug",
".pp": "vscode-icons:file-type-puppet",
".epp": "vscode-icons:file-type-puppet",
".purs": "vscode-icons:file-type-light-purescript",
".py": "vscode-icons:file-type-python",
".jsx": "vscode-icons:file-type-reactjs",
".spec.jsx": "vscode-icons:file-type-reactjs",
".test.jsx": "vscode-icons:file-type-reactjs",
".cjsx": "vscode-icons:file-type-reactjs",
".tsx": "vscode-icons:file-type-reactts",
".spec.tsx": "vscode-icons:file-type-reactts",
".test.tsx": "vscode-icons:file-type-reactts",
".res": "vscode-icons:file-type-rescript",
".resi": "vscode-icons:file-type-rescript",
".r": "vscode-icons:file-type-r",
".rmd": "vscode-icons:file-type-rmd",
".rb": "vscode-icons:file-type-ruby",
".erb": "vscode-icons:file-type-html",
".erb.html": "vscode-icons:file-type-html",
".html.erb": "vscode-icons:file-type-html",
".rs": "vscode-icons:file-type-rust",
".sass": "vscode-icons:file-type-sass",
".scss": "vscode-icons:file-type-sass",
".springbeans": "mdi:sprout",
".slim": "vscode-icons:file-type-slim",
".smarty.tpl": "vscode-icons:file-type-smarty",
".tpl": "vscode-icons:file-type-smarty",
".sbt": "vscode-icons:file-type-sbt",
".scala": "vscode-icons:file-type-scala",
".sol": "logos:ethereum-color",
".styl": "vscode-icons:file-type-light-stylus",
".svelte": "vscode-icons:file-type-svelte",
".swift": "vscode-icons:file-type-swift",
".sql": "vscode-icons:file-type-sql",
".soql": "vscode-icons:file-type-sql",
".tf": "vscode-icons:file-type-terraform",
".tf.json": "vscode-icons:file-type-terraform",
".tfvars": "vscode-icons:file-type-terraform",
".tfvars.json": "vscode-icons:file-type-terraform",
".tex": "vscode-icons:file-type-light-tex",
".sty": "vscode-icons:file-type-light-tex",
".dtx": "vscode-icons:file-type-light-tex",
".ins": "vscode-icons:file-type-light-tex",
".txt": "vscode-icons:file-type-text",
".toml": "vscode-icons:file-type-light-toml",
".twig": "vscode-icons:file-type-twig",
".ts": "vscode-icons:file-type-typescript",
".spec.ts": "vscode-icons:file-type-testts",
".test.ts": "vscode-icons:file-type-testts",
… (demo build 截断)import { getIconIds, type IconName } from "obsidian";
import { resolveOnlineIconifyId } from "./offlineIconify";
import type { FileTreeIconMode } from "./types";
export interface IconDescriptor {
icon: IconName;
iconifyId?: string;
colorClass?: string;
}
interface IconStyle {
icon?: IconName | IconName[];
colorClass?: string;
}
const DEFAULT_FILE_ICON: IconName = "file";
const DEFAULT_FOLDER_ICON: IconName = "folder";
const DEFAULT_FOLDER_OPEN_ICON: IconName = "folder-open";
const DEFAULT_FILE_COLOR = "ft-color-default";
const DEFAULT_FOLDER_COLOR = "ft-color-folder";
let availableIconSet: Set<string> | null = null;
function ensureIconSet(): Set<string> {
const ids = getIconIds() as string[];
// First call can run before Obsidian finishes registering icons; don't cache an empty set.
if (!availableIconSet || (availableIconSet.size === 0 && ids.length > 0)) {
availableIconSet = new Set(ids);
}
return availableIconSet;
}
/** Pick a known Obsidian icon id from candidates (file-tree helpers). */
export function resolveObsidianIcon(icon: string, fallback: IconName = "file"): IconName {
return safeIcon(icon as IconName, fallback);
}
function safeIcon(icon: IconName | IconName[] | undefined, fallback: IconName): IconName {
if (!icon) {
return fallback;
}
const iconSet = ensureIconSet();
if (Array.isArray(icon)) {
for (const candidate of icon) {
if (iconSet.has(candidate)) {
return candidate;
}
}
return fallback;
}
return iconSet.has(icon) ? icon : fallback;
}
const CANDIDATES = {
package: ["package", "archive", "box"] as IconName[],
lock: ["lock", "shield-lock", "shield"] as IconName[],
docs: ["book-open", "book-text", "file-text"] as IconName[],
security: ["shield", "shield-check", "shield-alert"] as IconName[],
git: ["git-branch", "git-compare", "git-merge"] as IconName[],
env: ["shield", "key-round", "lock"] as IconName[],
config: ["settings", "sliders-horizontal", "wrench"] as IconName[],
codeFile: ["file-code-2", "file-code", "code"] as IconName[],
jsonFile: ["file-json-2", "file-json", "braces"] as IconName[],
markdown: ["book-open", "file-text", "notebook-pen"] as IconName[],
textFile: ["file-text", "file", "notebook-text"] as IconName[],
style: ["palette", "paintbrush-2", "paintbrush"] as IconName[],
image: ["image", "image-up", "file-image"] as IconName[],
video: ["video", "clapperboard", "film"] as IconName[],
music: ["music", "audio-lines", "audio-waveform"] as IconName[],
archive: ["archive", "package", "box"] as IconName[],
database: ["database", "table", "cylinder"] as IconName[],
terminal: ["terminal", "square-terminal", "command"] as IconName[],
srcFolder: ["folder-code", "folder-git-2", "folder"] as IconName[],
docsFolder: ["book-open", "folder", "book-text"] as IconName[],
testFolder: ["flask-conical", "flask-round", "beaker"] as IconName[],
publicFolder: ["globe", "globe-2", "folder"] as IconName[],
assetsFolder: ["image", "folder", "file-image"] as IconName[]
};
const NAMED_FILE_STYLES: Record<string, IconStyle> = {
"package.json": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pnpm-workspace.yaml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pnpm-workspace.yml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"package-lock.json": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"pnpm-lock.yaml": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"yarn.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"bun.lockb": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"readme": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"readme.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"readme.mdx": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"changelog.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"contributing.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"license": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"license.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"security.md": { icon: CANDIDATES.security, colorClass: "ft-color-security" },
".gitignore": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".gitattributes": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".gitmodules": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".env": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.local": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.development": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.production": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.example": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
"dockerfile": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"docker-compose.yml": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"docker-compose.yaml": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
".dockerignore": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"tsconfig.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"tsconfig.base.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"jsconfig.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vite.config.ts": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vite.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"webpack.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"rollup.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"eslint.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"eslint.config.mjs": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"prettier.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"stylelint.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vitest.config.ts": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"jest.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"tailwind.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"postcss.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"makefile": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
"requirements.txt": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pyproject.toml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"poetry.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"go.mod": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"go.sum": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"cargo.toml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"cargo.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" }
};
const FOLDER_STYLES: Record<string, IconStyle> = {
src: { icon: CANDIDATES.srcFolder, colorClass: "ft-color-src" },
source: { icon: CANDIDATES.srcFolder, colorClass: "ft-color-src" },
docs: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
doc: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
blog: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
test: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
tests: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
__tests__: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
dist: { colorClass: "ft-color-dist" },
build: { colorClass: "ft-color-dist" },
out: { colorClass: "ft-color-dist" },
public: { icon: CANDIDATES.publicFolder, colorClass: "ft-color-public" },
assets: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
images: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
img: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
scripts: { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
script: { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
config: { icon: CANDIDATES.config, colorClass: "ft-color-config" },
node_modules: { colorClass: "ft-color-package" },
types: { colorClass: "ft-color-typescript" },
style: { colorClass: "ft-color-style" },
styles: { colorClass: "ft-color-style" },
database: { icon: CANDIDATES.database, colorClass: "ft-color-database" }
};
const EXTENSION_STYLES: Record<string, IconStyle> = {
".d.ts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".ts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".tsx": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".mts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".cts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".js": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".jsx": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".mjs": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".cjs": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".vue": { icon: CANDIDATES.codeFile, colorClass: "ft-color-vue" },
".md": { icon: CANDIDATES.markdown, colorClass: "ft-color-markdown" },
".mdx": { icon: CANDIDATES.markdown, colorClass: "ft-color-markdown" },
".txt": { icon: CANDIDATES.textFile, colorClass: "ft-color-doc" },
".pdf": { icon: CANDIDATES.textFile, colorClass: "ft-color-doc" },
".json": { icon: CANDIDATES.jsonFile, colorClass: "ft-color-json" },
".jsonc": { icon: CANDIDATES.jsonFile, colorClass: "ft-color-json" },
".yaml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".yml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".toml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".ini": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".conf": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".css": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".scss": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".sass": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".less": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".styl": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".html": { icon: CANDIDATES.codeFile, colorClass: "ft-color-markup" },
".xml": { icon: CANDIDATES.codeFile, colorClass: "ft-color-markup" },
".svg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".png": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".jpg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".jpeg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".gif": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".webp": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".avif": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".mp4": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".mov": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".avi": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".webm": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".mp3": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".wav": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".ogg": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".m4a": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".zip": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".rar": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".7z": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".gz": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".tar": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".sql": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".sqlite": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".db": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".sh": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".bash": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".zsh": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".ps1": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".bat": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".cmd": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" }
};
const PARTIAL_STYLES: Array<{ include: string; style: IconStyle }> = [
{ include: "test", style: { colorClass: "ft-color-tests" } },
{ include: "spec", style: { colorClass: "ft-color-tests" } },
{ include: "mock", style: { colorClass: "ft-color-tests" } },
{ include: "config", style: { icon: CANDIDATES.config, colorClass: "ft-color-config" } },
{ include: "docker", style: { icon: CANDIDATES.package, colorClass: "ft-color-docker" } },
{ include: "readme", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "changelog", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "license", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "security", style: { icon: CANDIDATES.security, colorClass: "ft-color-security" } },
{ include: ".lock", style: { icon: CANDIDATES.lock, colorClass: "ft-color-lock" } }
];
function applyStyle(baseIcon: IconName, defaultColor: string, style: IconStyle | undefined, iconifyId?: string): IconDescriptor {
return {
icon: safeIcon(style?.icon, baseIcon),
iconifyId,
colorClass: style?.colorClass ?? defaultColor
};
}
function pickBaseName(value: string): string {
const normalized = value.replace(/\\/g, "/");
const segment = normalized.split("/").pop();
return segment ?? normalized;
}
function getExtensionCandidates(baseName: string): string[] {
const candidates: string[] = [];
let extension = baseName;
const firstDotIndex = extension.indexOf(".");
if (firstDotIndex === -1) {
return candidates;
}
extension = extension.slice(firstDotIndex);
while (extension !== "") {
candidates.push(extension);
const nextDotIndex = extension.indexOf(".", 1);
if (nextDotIndex === -1) {
break;
}
extension = extension.slice(nextDotIndex);
}
return candidates;
}
export function resolveNodeIcon(
fileName: string,
nodeType: "folder" | "file",
expanded: boolean,
mode: FileTreeIconMode
): IconDescriptor {
const normalizedPath = fileName.replace(/\\/g, "/").toLowerCase();
const baseName = pickBaseName(normalizedPath);
const iconifyId = mode === "colored"
? resolveOnlineIconifyId(normalizedPath, nodeType, expanded)
: undefined;
if (mode === "simple") {
if (nodeType === "folder") {
return { icon: expanded ? DEFAULT_FOLDER_OPEN_ICON : DEFAULT_FOLDER_ICON };
}
return { icon: DEFAULT_FILE_ICON };
}
if (nodeType === "folder") {
const folderStyle = FOLDER_STYLES[baseName];
return applyStyle(
expanded ? DEFAULT_FOLDER_OPEN_ICON : DEFAULT_FOLDER_ICON,
DEFAULT_FOLDER_COLOR,
folderStyle,
iconifyId
);
}
const namedStyle = NAMED_FILE_STYLES[baseName];
if (namedStyle) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, namedStyle, iconifyId);
}
const extensionCandidates = getExtensionCandidates(baseName);
for (const extension of extensionCandidates) {
const extensionStyle = EXTENSION_STYLES[extension];
if (extensionStyle) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, extensionStyle, iconifyId);
}
}
for (const item of PARTIAL_STYLES) {
if (normalizedPath.includes(item.include)) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, item.style, iconifyId);
}
}
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, undefined, iconifyId);
}
import {
App,
Component,
MarkdownPostProcessorContext,
MarkdownRenderChild,
MarkdownRenderer
} from "obsidian";
import { applyVuepressMarkdownTransforms } from "../render/markdown-transforms";
import { processIconifyIcons } from "../render/iconify-online";
export interface PlumeMarkdownContext {
app: App;
sourcePath: string;
component: Component;
postProcessorCtx?: MarkdownPostProcessorContext;
}
function createRenderToken(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function attachRenderChild(
host: HTMLElement,
ctx: PlumeMarkdownContext
): MarkdownRenderChild {
const child = new MarkdownRenderChild(host);
if (ctx.postProcessorCtx) {
ctx.postProcessorCtx.addChild(child);
} else {
ctx.component.addChild(child);
}
return child;
}
/**
* Render markdown into `container` using Obsidian's renderer with proper
* lifecycle management (MarkdownRenderChild). Cancels stale async renders via token.
*/
export async function renderPlumeMarkdown(
container: HTMLElement,
markdown: string,
ctx: PlumeMarkdownContext
): Promise<void> {
const source = applyVuepressMarkdownTransforms(markdown);
if (!source.trim()) {
return;
}
const token = createRenderToken();
container.dataset.plumeMdToken = token;
container.empty();
const host = document.createElement("div");
host.classList.add("markdown-rendered");
container.appendChild(host);
const child = attachRenderChild(host, ctx);
try {
await MarkdownRenderer.render(ctx.app, source, host, ctx.sourcePath, child);
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
// Hoist even when `container` is not yet in the live preview tree (nested blocks
// are often built inside a detached staging host before append).
while (host.firstChild) {
container.appendChild(host.firstChild);
}
host.remove();
await processIconifyIcons(container);
} catch {
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
container.empty();
container.textContent = source;
}
}
/**
* Render into a staging host, then move children into `container` (used when
* we must not leave an extra wrapper in the DOM).
*/
export async function renderPlumeMarkdownInto(
container: HTMLElement,
markdown: string,
ctx: PlumeMarkdownContext
): Promise<void> {
const source = applyVuepressMarkdownTransforms(markdown);
if (!source.trim()) {
return;
}
const token = createRenderToken();
container.dataset.plumeMdToken = token;
container.empty();
const host = document.createElement("div");
host.classList.add("markdown-rendered");
container.appendChild(host);
const child = attachRenderChild(host, ctx);
try {
await MarkdownRenderer.render(ctx.app, source, host, ctx.sourcePath, child);
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
while (host.firstChild) {
container.insertBefore(host.firstChild, host);
}
host.remove();
await processIconifyIcons(container);
} catch {
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
container.empty();
container.textContent = source;
}
}
import { VUEPRESS_FILE_ICON_RULES } from "./generated/vuepressFileIcons";
interface OfflineIconStyle {
icon?: string | readonly string[];
openIcon?: string | readonly string[];
}
const DEFAULT_FILE_OFFLINE_ICON = "vscode-icons:default-file";
const DEFAULT_FOLDER_OFFLINE_ICON = "vscode-icons:default-folder";
const DEFAULT_FOLDER_OPEN_OFFLINE_ICON = "vscode-icons:default-folder-opened";
const ICONIFY_PREFIX_ALIASES: Record<string, string> = {
"vvscode-icons": "vscode-icons"
};
export function normalizeIconifyId(iconId: string): string {
const trimmed = iconId.trim();
const separator = trimmed.indexOf(":");
if (separator === -1) {
return trimmed;
}
const prefix = trimmed.slice(0, separator);
const name = trimmed.slice(separator + 1);
return `${ICONIFY_PREFIX_ALIASES[prefix] ?? prefix}:${name}`;
}
const OFFLINE_CANDIDATES = {
package: ["logos:npm-icon", "vscode-icons:file-type-npm", "vscode-icons:file-type-package", "vscode-icons:file-type-node"],
lock: ["vscode-icons:file-type-package", "vscode-icons:file-type-json"],
docs: ["vscode-icons:file-type-markdown", "vscode-icons:file-type-text"],
security: ["logos:github-icon", "vscode-icons:file-type-config"],
git: ["logos:git-icon", "vscode-icons:file-type-git"],
env: ["vscode-icons:file-type-dotenv", "vscode-icons:file-type-config"],
docker: ["logos:docker-icon", "vscode-icons:file-type-docker"],
config: ["vscode-icons:file-type-config", "vscode-icons:file-type-tsconfig"],
scripts: ["vscode-icons:file-type-shell", "vscode-icons:file-type-powershell", "vscode-icons:file-type-bat"],
prettier: ["vscode-icons:file-type-light-prettier", "vscode-icons:file-type-prettier", "vscode-icons:file-type-config"],
eslint: ["vscode-icons:file-type-eslint", "vscode-icons:file-type-eslint2", "vscode-icons:file-type-config"],
stylelint: ["vscode-icons:file-type-light-stylelint", "vscode-icons:file-type-stylelint", "vscode-icons:file-type-config"],
commitlint: ["vscode-icons:file-type-commitlint", "vscode-icons:file-type-config"],
editorconfig: ["vscode-icons:file-type-editorconfig", "vscode-icons:file-type-config"],
playwright: ["vscode-icons:file-type-playwright", "vscode-icons:file-type-jest"],
cypress: ["vscode-icons:file-type-cypress", "vscode-icons:file-type-jest"],
turbo: ["vscode-icons:file-type-light-turbo", "vscode-icons:file-type-config"],
nx: ["vscode-icons:file-type-light-nx", "vscode-icons:file-type-config"],
biome: ["vscode-icons:file-type-biome", "vscode-icons:file-type-config"],
react: ["vscode-icons:file-type-reactjs", "vscode-icons:file-type-js-official"],
next: ["vscode-icons:file-type-light-next", "vscode-icons:file-type-reactjs"],
nuxt: ["vscode-icons:file-type-nuxt", "vscode-icons:file-type-vue"],
svelte: ["vscode-icons:file-type-svelte", "vscode-icons:file-type-js-official"],
astro: ["vscode-icons:file-type-light-astro", "vscode-icons:file-type-js-official"],
deno: ["vscode-icons:file-type-light-deno", "vscode-icons:file-type-js-official"],
python: ["vscode-icons:file-type-python", "vscode-icons:file-type-package"],
go: ["vscode-icons:file-type-go-gopher", "vscode-icons:file-type-package"],
cargo: ["vscode-icons:file-type-cargo", "vscode-icons:file-type-rust"],
java: ["vscode-icons:file-type-java"],
php: ["vscode-icons:file-type-php3"],
c: ["vscode-icons:file-type-c"],
cpp: ["vscode-icons:file-type-cpp"],
csharp: ["vscode-icons:file-type-csharp"],
kotlin: ["vscode-icons:file-type-kotlin"],
ruby: ["vscode-icons:file-type-ruby"],
swift: ["vscode-icons:file-type-swift"],
zig: ["vscode-icons:file-type-zig"],
wasm: ["vscode-icons:file-type-wasm"],
mysql: ["vscode-icons:file-type-mysql", "vscode-icons:file-type-sql"],
pgsql: ["vscode-icons:file-type-pgsql", "vscode-icons:file-type-sql"],
srcFolder: ["vscode-icons:folder-type-src"],
srcFolderOpen: ["vscode-icons:folder-type-src-opened", "vscode-icons:folder-type-src"],
docsFolder: ["vscode-icons:folder-type-docs"],
docsFolderOpen: ["vscode-icons:folder-type-docs-opened", "vscode-icons:folder-type-docs"],
testFolder: ["vscode-icons:folder-type-test"],
testFolderOpen: ["vscode-icons:folder-type-test-opened", "vscode-icons:folder-type-test"],
distFolder: ["vscode-icons:folder-type-dist"],
distFolderOpen: ["vscode-icons:folder-type-dist-opened", "vscode-icons:folder-type-dist"],
publicFolder: ["vscode-icons:folder-type-public"],
publicFolderOpen: ["vscode-icons:folder-type-public-opened", "vscode-icons:folder-type-public"],
imagesFolder: ["vscode-icons:folder-type-images"],
imagesFolderOpen: ["vscode-icons:folder-type-images-opened", "vscode-icons:folder-type-images"],
assetsFolder: ["vscode-icons:folder-type-asset"],
assetsFolderOpen: ["vscode-icons:folder-type-asset-opened", "vscode-icons:folder-type-asset"],
scriptsFolder: ["vscode-icons:folder-type-script"],
scriptsFolderOpen: ["vscode-icons:folder-type-script-opened", "vscode-icons:folder-type-script"],
configFolder: ["vscode-icons:folder-type-config"],
configFolderOpen: ["vscode-icons:folder-type-config-opened", "vscode-icons:folder-type-config"],
nodeModulesFolder: ["vscode-icons:folder-type-light-node"],
nodeModulesFolderOpen: ["vscode-icons:folder-type-light-node-opened", "vscode-icons:folder-type-light-node"],
styleFolder: ["vscode-icons:folder-type-theme"],
styleFolderOpen: ["vscode-icons:folder-type-theme-opened", "vscode-icons:folder-type-theme"],
databaseFolder: ["vscode-icons:folder-type-db"],
databaseFolderOpen: ["vscode-icons:folder-type-db-opened", "vscode-icons:folder-type-db"],
componentFolder: ["vscode-icons:folder-type-component"],
componentFolderOpen: ["vscode-icons:folder-type-component-opened", "vscode-icons:folder-type-component"],
hookFolder: ["vscode-icons:folder-type-hook"],
hookFolderOpen: ["vscode-icons:folder-type-hook-opened", "vscode-icons:folder-type-hook"],
apiFolder: ["vscode-icons:folder-type-api"],
apiFolderOpen: ["vscode-icons:folder-type-api-opened", "vscode-icons:folder-type-api"],
serverFolder: ["vscode-icons:folder-type-server"],
serverFolderOpen: ["vscode-icons:folder-type-server-opened", "vscode-icons:folder-type-server"],
clientFolder: ["vscode-icons:folder-type-client"],
clientFolderOpen: ["vscode-icons:folder-type-client-opened", "vscode-icons:folder-type-client"],
libraryFolder: ["vscode-icons:folder-type-library"],
libraryFolderOpen: ["vscode-icons:folder-type-library-opened", "vscode-icons:folder-type-library"],
includeFolder: ["vscode-icons:folder-type-include"],
includeFolderOpen: ["vscode-icons:folder-type-include-opened", "vscode-icons:folder-type-include"],
localeFolder: ["vscode-icons:folder-type-locale"],
localeFolderOpen: ["vscode-icons:folder-type-locale-opened", "vscode-icons:folder-type-locale"],
pluginFolder: ["vscode-icons:folder-type-plugin"],
pluginFolderOpen: ["vscode-icons:folder-type-plugin-opened", "vscode-icons:folder-type-plugin"],
packageFolder: ["vscode-icons:folder-type-package"],
packageFolderOpen: ["vscode-icons:folder-type-package-opened", "vscode-icons:folder-type-package"],
appFolder: ["vscode-icons:folder-type-app"],
appFolderOpen: ["vscode-icons:folder-type-app-opened", "vscode-icons:folder-type-app"],
viewFolder: ["vscode-icons:folder-type-view"],
viewFolderOpen: ["vscode-icons:folder-type-view-opened", "vscode-icons:folder-type-view"],
modelFolder: ["vscode-icons:folder-type-model"],
modelFolderOpen: ["vscode-icons:folder-type-model-opened", "vscode-icons:folder-type-model"],
controllerFolder: ["vscode-icons:folder-type-controller"],
controllerFolderOpen: ["vscode-icons:folder-type-controller-opened", "vscode-icons:folder-type-controller"],
servicesFolder: ["vscode-icons:folder-type-services"],
servicesFolderOpen: ["vscode-icons:folder-type-services-opened", "vscode-icons:folder-type-services"],
typescript: ["vscode-icons:file-type-typescript-official"],
javascript: ["vscode-icons:file-type-js-official"],
vue: ["vscode-icons:file-type-vue"],
svelteType: ["vscode-icons:file-type-svelte"],
astroType: ["vscode-icons:file-type-light-astro"],
reactType: ["vscode-icons:file-type-reactjs"],
denoType: ["vscode-icons:file-type-light-deno"],
markdown: ["vscode-icons:file-type-markdown"],
text: ["vscode-icons:file-type-text"],
pdf: ["vscode-icons:file-type-pdf2", "vscode-icons:file-type-text"],
json: ["vscode-icons:file-type-json"],
yaml: ["vscode-icons:file-type-light-yaml"],
toml: ["vscode-icons:file-type-light-toml"],
ini: ["vscode-icons:file-type-light-ini", "vscode-icons:file-type-config"],
css: ["vscode-icons:file-type-css"],
scss: ["vscode-icons:file-type-scss", "vscode-icons:file-type-css"],
less: ["vscode-icons:file-type-less", "vscode-icons:file-type-css"],
stylus: ["vscode-icons:file-type-light-stylus", "vscode-icons:file-type-css"],
html: ["vscode-icons:file-type-html"],
xml: ["vscode-icons:file-type-xml"],
svg: ["vscode-icons:file-type-svg", "vscode-icons:file-type-image"],
image: ["vscode-icons:file-type-image"],
video: ["vscode-icons:file-type-video"],
audio: ["vscode-icons:file-type-audio"],
archive: ["vscode-icons:file-type-zip"],
database: ["vscode-icons:file-type-db", "vscode-icons:file-type-sql", "vscode-icons:file-type-sqlite"],
shell: ["vscode-icons:file-type-shell"],
powershell: ["vscode-icons:file-type-powershell", "vscode-icons:file-type-shell"],
batch: ["vscode-icons:file-type-bat", "vscode-icons:file-type-shell"],
javaType: ["vscode-icons:file-type-java"],
phpType: ["vscode-icons:file-type-php3"],
cType: ["vscode-icons:file-type-c"],
cppType: ["vscode-icons:file-type-cpp"],
csharpType: ["vscode-icons:file-type-csharp"],
kotlinType: ["vscode-icons:file-type-kotlin"],
rubyType: ["vscode-icons:file-type-ruby"],
swiftType: ["vscode-icons:file-type-swift"],
zigType: ["vscode-icons:file-type-zig"],
wasmType: ["vscode-icons:file-type-wasm"],
mysqlType: ["vscode-icons:file-type-mysql", "vscode-icons:file-type-sql"],
pgsqlType: ["vscode-icons:file-type-pgsql", "vscode-icons:file-type-sql"]
} as const;
const OFFLINE_NAMED_FILE_STYLES: Record<string, OfflineIconStyle> = {
"package.json": { icon: OFFLINE_CANDIDATES.package },
"npm-shrinkwrap.json": { icon: OFFLINE_CANDIDATES.lock },
"pnpm-workspace.yaml": { icon: "vscode-icons:file-type-light-pnpm" },
"pnpm-workspace.yml": { icon: "vscode-icons:file-type-light-pnpm" },
"package-lock.json": { icon: OFFLINE_CANDIDATES.lock },
"pnpm-lock.yaml": { icon: "vscode-icons:file-type-light-pnpm" },
"yarn.lock": { icon: "vscode-icons:file-type-yarn" },
"bun.lockb": { icon: "vscode-icons:file-type-bun" },
"pnpmfile.cjs": { icon: "vscode-icons:file-type-light-pnpm" },
"pnpmfile.mjs": { icon: "vscode-icons:file-type-light-pnpm" },
".npmrc": { icon: "vscode-icons:file-type-npm" },
".yarnrc": { icon: "vscode-icons:file-type-yarn" },
".yarnrc.yml": { icon: "vscode-icons:file-type-yarn" },
"bunfig.toml": { icon: "vscode-icons:file-type-bunfig" },
"deno.json": { icon: OFFLINE_CANDIDATES.deno },
"deno.jsonc": { icon: OFFLINE_CANDIDATES.deno },
"turbo.json": { icon: OFFLINE_CANDIDATES.turbo },
"nx.json": { icon: OFFLINE_CANDIDATES.nx },
"biome.json": { icon: OFFLINE_CANDIDATES.biome },
"biome.jsonc": { icon: OFFLINE_CANDIDATES.biome },
"readme": { icon: OFFLINE_CANDIDATES.docs },
"readme.md": { icon: OFFLINE_CANDIDATES.docs },
"readme.mdx": { icon: OFFLINE_CANDIDATES.docs },
"changelog.md": { icon: OFFLINE_CANDIDATES.docs },
"contributing.md": { icon: OFFLINE_CANDIDATES.docs },
"license": { icon: "vscode-icons:file-type-license" },
"license.md": { icon: "vscode-icons:file-type-license" },
"security.md": { icon: OFFLINE_CANDIDATES.security },
".gitignore": { icon: OFFLINE_CANDIDATES.git },
".gitattributes": { icon: OFFLINE_CANDIDATES.git },
".gitmodules": { icon: OFFLINE_CANDIDATES.git },
".env": { icon: OFFLINE_CANDIDATES.env },
".env.local": { icon: OFFLINE_CANDIDATES.env },
".env.development": { icon: OFFLINE_CANDIDATES.env },
".env.production": { icon: OFFLINE_CANDIDATES.env },
".env.example": { icon: OFFLINE_CANDIDATES.env },
"dockerfile": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.yml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.yaml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.override.yml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.override.yaml": { icon: OFFLINE_CANDIDATES.docker },
".dockerignore": { icon: OFFLINE_CANDIDATES.docker },
"tsconfig.json": { icon: "vscode-icons:file-type-tsconfig" },
"tsconfig.base.json": { icon: "vscode-icons:file-type-tsconfig" },
"jsconfig.json": { icon: "vscode-icons:file-type-jsconfig" },
"vite.config.ts": { icon: "vscode-icons:file-type-vite" },
"vite.config.js": { icon: "vscode-icons:file-type-vite" },
"webpack.config.js": { icon: "vscode-icons:file-type-webpack" },
"rollup.config.js": { icon: "vscode-icons:file-type-rollup" },
"eslint.config.js": { icon: OFFLINE_CANDIDATES.eslint },
"eslint.config.mjs": { icon: OFFLINE_CANDIDATES.eslint },
"eslint.config.cjs": { icon: OFFLINE_CANDIDATES.eslint },
"prettier.config.js": { icon: OFFLINE_CANDIDATES.prettier },
"prettier.config.mjs": { icon: OFFLINE_CANDIDATES.prettier },
"prettier.config.cjs": { icon: OFFLINE_CANDIDATES.prettier },
"stylelint.config.js": { icon: OFFLINE_CANDIDATES.stylelint },
"stylelint.config.mjs": { icon: OFFLINE_CANDIDATES.stylelint },
"stylelint.config.cjs": { icon: OFFLINE_CANDIDATES.stylelint },
"vitest.config.ts": { icon: "vscode-icons:file-type-vitest" },
"jest.config.js": { icon: "vscode-icons:file-type-jest" },
"playwright.config.ts": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.js": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.mts": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.mjs": { icon: OFFLINE_CANDIDATES.playwright },
"cypress.config.ts": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.js": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.mts": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.mjs": { icon: OFFLINE_CANDIDATES.cypress },
"commitlint.config.js": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.cjs": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.mjs": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.ts": { icon: OFFLINE_CANDIDATES.commitlint },
".editorconfig": { icon: OFFLINE_CANDIDATES.editorconfig },
".eslintrc": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.js": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.cjs": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.yml": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.yaml": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.json": { icon: OFFLINE_CANDIDATES.eslint },
".prettierrc": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.js": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.cjs": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.yml": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.yaml": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.json": { icon: OFFLINE_CANDIDATES.prettier },
".stylelintrc": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.js": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.cjs": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.yml": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.yaml": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.json": { icon: OFFLINE_CANDIDATES.stylelint },
"tailwind.config.js": { icon: "vscode-icons:file-type-tailwind" },
"postcss.config.js": { icon: "vscode-icons:file-type-postcss" },
"makefile": { icon: OFFLINE_CANDIDATES.scripts },
".nvmrc": { icon: OFFLINE_CANDIDATES.package },
".node-version": { icon: OFFLINE_CANDIDATES.package },
"requirements.txt": { icon: OFFLINE_CANDIDATES.python },
".python-version": { icon: OFFLINE_CANDIDATES.python },
"pyproject.toml": { icon: OFFLINE_CANDIDATES.python },
"poetry.lock": { icon: OFFLINE_CANDIDATES.python },
"gemfile": { icon: OFFLINE_CANDIDATES.ruby },
"gemfile.lock": { icon: OFFLINE_CANDIDATES.ruby },
".ruby-version": { icon: OFFLINE_CANDIDATES.ruby },
"go.mod": { icon: OFFLINE_CANDIDATES.go },
"go.sum": { icon: OFFLINE_CANDIDATES.go },
"cargo.toml": { icon: OFFLINE_CANDIDATES.cargo },
"cargo.lock": { icon: OFFLINE_CANDIDATES.cargo },
"composer.json": { icon: OFFLINE_CANDIDATES.php },
"composer.lock": { icon: OFFLINE_CANDIDATES.php },
"pom.xml": { icon: OFFLINE_CANDIDATES.java },
"build.gradle": { icon: OFFLINE_CANDIDATES.java },
"build.gradle.kts": { icon: OFFLINE_CANDIDATES.kotlin },
"settings.gradle": { icon: OFFLINE_CANDIDATES.java },
"settings.gradle.kts": { icon: OFFLINE_CANDIDATES.kotlin }
};
const OFFLINE_FOLDER_STYLES: Record<string, OfflineIconStyle> = {
src: { icon: OFFLINE_CANDIDATES.srcFolder, openIcon: OFFLINE_CANDIDATES.srcFolderOpen },
source: { icon: OFFLINE_CANDIDATES.srcFolder, openIcon: OFFLINE_CANDIDATES.srcFolderOpen },
docs: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
doc: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
blog: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
test: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
tests: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
__tests__: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
dist: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
build: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
out: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
public: { icon: OFFLINE_CANDIDATES.publicFolder, openIcon: OFFLINE_CANDIDATES.publicFolderOpen },
assets: { icon: OFFLINE_CANDIDATES.assetsFolder, openIcon: OFFLINE_CANDIDATES.assetsFolderOpen },
images: { icon: OFFLINE_CANDIDATES.imagesFolder, openIcon: OFFLINE_CANDIDATES.imagesFolderOpen },
img: { icon: OFFLINE_CANDIDATES.imagesFolder, openIcon: OFFLINE_CANDIDATES.imagesFolderOpen },
scripts: { icon: OFFLINE_CANDIDATES.scriptsFolder, openIcon: OFFLINE_CANDIDATES.scriptsFolderOpen },
script: { icon: OFFLINE_CANDIDATES.scriptsFolder, openIcon: OFFLINE_CANDIDATES.scriptsFolderOpen },
config: { icon: OFFLINE_CANDIDATES.configFolder, openIcon: OFFLINE_CANDIDATES.configFolderOpen },
node_modules: { icon: OFFLINE_CANDIDATES.nodeModulesFolder, openIcon: OFFLINE_CANDIDATES.nodeModulesFolderOpen },
style: { icon: OFFLINE_CANDIDATES.styleFolder, openIcon: OFFLINE_CANDIDATES.styleFolderOpen },
styles: { icon: OFFLINE_CANDIDATES.styleFolder, openIcon: OFFLINE_CANDIDATES.styleFolderOpen },
database: { icon: OFFLINE_CANDIDATES.databaseFolder, openIcon: OFFLINE_CANDIDATES.databaseFolderOpen },
db: { icon: OFFLINE_CANDIDATES.databaseFolder, openIcon: OFFLINE_CANDIDATES.databaseFolderOpen },
component: { icon: OFFLINE_CANDIDATES.componentFolder, openIcon: OFFLINE_CANDIDATES.componentFolderOpen },
components: { icon: OFFLINE_CANDIDATES.componentFolder, openIcon: OFFLINE_CANDIDATES.componentFolderOpen },
hook: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
hooks: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
composable: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
composables: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
api: { icon: OFFLINE_CANDIDATES.apiFolder, openIcon: OFFLINE_CANDIDATES.apiFolderOpen },
apis: { icon: OFFLINE_CANDIDATES.apiFolder, openIcon: OFFLINE_CANDIDATES.apiFolderOpen },
server: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
servers: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
backend: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
backends: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
client: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
clients: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
frontend: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
frontends: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
lib: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
libs: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
library: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
libraries: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
include: { icon: OFFLINE_CANDIDATES.includeFolder, openIcon: OFFLINE_CANDIDATES.includeFolderOpen },
includes: { icon: OFFLINE_CANDIDATES.includeFolder, openIcon: OFFLINE_CANDIDATES.includeFolderOpen },
locale: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
locales: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
i18n: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
plugin: { icon: OFFLINE_CANDIDATES.pluginFolder, openIcon: OFFLINE_CANDIDATES.pluginFolderOpen },
plugins: { icon: OFFLINE_CANDIDATES.pluginFolder, openIcon: OFFLINE_CANDIDATES.pluginFolderOpen },
package: { icon: OFFLINE_CANDIDATES.packageFolder, openIcon: OFFLINE_CANDIDATES.packageFolderOpen },
packages: { icon: OFFLINE_CANDIDATES.packageFolder, openIcon: OFFLINE_CANDIDATES.packageFolderOpen },
app: { icon: OFFLINE_CANDIDATES.appFolder, openIcon: OFFLINE_CANDIDATES.appFolderOpen },
apps: { icon: OFFLINE_CANDIDATES.appFolder, openIcon: OFFLINE_CANDIDATES.appFolderOpen },
view: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
views: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
page: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
pages: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
model: { icon: OFFLINE_CANDIDATES.modelFolder, openIcon: OFFLINE_CANDIDATES.modelFolderOpen },
models: { icon: OFFLINE_CANDIDATES.modelFolder, openIcon: OFFLINE_CANDIDATES.modelFolderOpen },
controller: { icon: OFFLINE_CANDIDATES.controllerFolder, openIcon: OFFLINE_CANDIDATES.controllerFolderOpen },
controllers: { icon: OFFLINE_CANDIDATES.controllerFolder, openIcon: OFFLINE_CANDIDATES.controllerFolderOpen },
service: { icon: OFFLINE_CANDIDATES.servicesFolder, openIcon: OFFLINE_CANDIDATES.servicesFolderOpen },
services: { icon: OFFLINE_CANDIDATES.servicesFolder, openIcon: OFFLINE_CANDIDATES.servicesFolderOpen }
};
const OFFLINE_EXTENSION_STYLES: Record<string, OfflineIconStyle> = {
".d.ts": { icon: OFFLINE_CANDIDATES.typescript },
".ts": { icon: OFFLINE_CANDIDATES.typescript },
".tsx": { icon: OFFLINE_CANDIDATES.typescript },
".mts": { icon: OFFLINE_CANDIDATES.typescript },
".cts": { icon: OFFLINE_CANDIDATES.typescript },
".js": { icon: OFFLINE_CANDIDATES.javascript },
".jsx": { icon: OFFLINE_CANDIDATES.javascript },
".mjs": { icon: OFFLINE_CANDIDATES.javascript },
".cjs": { icon: OFFLINE_CANDIDATES.javascript },
".react.tsx": { icon: OFFLINE_CANDIDATES.reactType },
".react.jsx": { icon: OFFLINE_CANDIDATES.reactType },
".vue": { icon: OFFLINE_CANDIDATES.vue },
".svelte": { icon: OFFLINE_CANDIDATES.svelteType },
".astro": { icon: OFFLINE_CANDIDATES.astroType },
".md": { icon: OFFLINE_CANDIDATES.markdown },
".mdx": { icon: OFFLINE_CANDIDATES.markdown },
".txt": { icon: OFFLINE_CANDIDATES.text },
".pdf": { icon: OFFLINE_CANDIDATES.pdf },
".json": { icon: OFFLINE_CANDIDATES.json },
".jsonc": { icon: OFFLINE_CANDIDATES.json },
".yaml": { icon: OFFLINE_CANDIDATES.yaml },
".yml": { icon: OFFLINE_CANDIDATES.yaml },
".toml": { icon: OFFLINE_CANDIDATES.toml },
".ini": { icon: OFFLINE_CANDIDATES.ini },
".conf": { icon: OFFLINE_CANDIDATES.ini },
".css": { icon: OFFLINE_CANDIDATES.css },
".scss": { icon: OFFLINE_CANDIDATES.scss },
".sass": { icon: OFFLINE_CANDIDATES.scss },
".less": { icon: OFFLINE_CANDIDATES.less },
".styl": { icon: OFFLINE_CANDIDATES.stylus },
".html": { icon: OFFLINE_CANDIDATES.html },
".xml": { icon: OFFLINE_CANDIDATES.xml },
".svg": { icon: OFFLINE_CANDIDATES.svg },
".png": { icon: OFFLINE_CANDIDATES.image },
".jpg": { icon: OFFLINE_CANDIDATES.image },
".jpeg": { icon: OFFLINE_CANDIDATES.image },
".gif": { icon: OFFLINE_CANDIDATES.image },
".webp": { icon: OFFLINE_CANDIDATES.image },
".avif": { icon: OFFLINE_CANDIDATES.image },
".mp4": { icon: OFFLINE_CANDIDATES.video },
".mov": { icon: OFFLINE_CANDIDATES.video },
".avi": { icon: OFFLINE_CANDIDATES.video },
".webm": { icon: OFFLINE_CANDIDATES.video },
".mp3": { icon: OFFLINE_CANDIDATES.audio },
".wav": { icon: OFFLINE_CANDIDATES.audio },
".ogg": { icon: OFFLINE_CANDIDATES.audio },
".m4a": { icon: OFFLINE_CANDIDATES.audio },
".zip": { icon: OFFLINE_CANDIDATES.archive },
".rar": { icon: OFFLINE_CANDIDATES.archive },
".7z": { icon: OFFLINE_CANDIDATES.archive },
".gz": { icon: OFFLINE_CANDIDATES.archive },
".tar": { icon: OFFLINE_CANDIDATES.archive },
".sql": { icon: OFFLINE_CANDIDATES.database },
".sqlite": { icon: OFFLINE_CANDIDATES.database },
".db": { icon: OFFLINE_CANDIDATES.database },
".mysql": { icon: OFFLINE_CANDIDATES.mysqlType },
".pgsql": { icon: OFFLINE_CANDIDATES.pgsqlType },
".java": { icon: OFFLINE_CANDIDATES.javaType },
".php": { icon: OFFLINE_CANDIDATES.phpType },
".c": { icon: OFFLINE_CANDIDATES.cType },
".h": { icon: OFFLINE_CANDIDATES.cType },
".cpp": { icon: OFFLINE_CANDIDATES.cppType },
".cc": { icon: OFFLINE_CANDIDATES.cppType },
".cxx": { icon: OFFLINE_CANDIDATES.cppType },
".hpp": { icon: OFFLINE_CANDIDATES.cppType },
".cs": { icon: OFFLINE_CANDIDATES.csharpType },
".kt": { icon: OFFLINE_CANDIDATES.kotlinType },
".kts": { icon: OFFLINE_CANDIDATES.kotlinType },
".rb": { icon: OFFLINE_CANDIDATES.rubyType },
".swift": { icon: OFFLINE_CANDIDATES.swiftType },
".zig": { icon: OFFLINE_CANDIDATES.zigType },
".wasm": { icon: OFFLINE_CANDIDATES.wasmType },
".sh": { icon: OFFLINE_CANDIDATES.shell },
".bash": { icon: OFFLINE_CANDIDATES.shell },
".zsh": { icon: OFFLINE_CANDIDATES.shell },
".ps1": { icon: OFFLINE_CANDIDATES.powershell },
".bat": { icon: OFFLINE_CANDIDATES.batch },
".cmd": { icon: OFFLINE_CANDIDATES.batch }
};
const OFFLINE_PARTIAL_STYLES: Array<{ include: string; style: OfflineIconStyle }> = [
{ include: "test", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "spec", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "mock", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "playwright", style: { icon: OFFLINE_CANDIDATES.playwright } },
{ include: "cypress", style: { icon: OFFLINE_CANDIDATES.cypress } },
{ include: "config", style: { icon: OFFLINE_CANDIDATES.config } },
{ include: "eslint", style: { icon: OFFLINE_CANDIDATES.eslint } },
{ include: "prettier", style: { icon: OFFLINE_CANDIDATES.prettier } },
{ include: "stylelint", style: { icon: OFFLINE_CANDIDATES.stylelint } },
{ include: "commitlint", style: { icon: OFFLINE_CANDIDATES.commitlint } },
{ include: "biome", style: { icon: OFFLINE_CANDIDATES.biome } },
{ include: "turbo", style: { icon: OFFLINE_CANDIDATES.turbo } },
{ include: "nx", style: { icon: OFFLINE_CANDIDATES.nx } },
{ include: "react", style: { icon: OFFLINE_CANDIDATES.react } },
{ include: "next", style: { icon: OFFLINE_CANDIDATES.next } },
{ include: "nuxt", style: { icon: OFFLINE_CANDIDATES.nuxt } },
{ include: "svelte", style: { icon: OFFLINE_CANDIDATES.svelte } },
{ include: "astro", style: { icon: OFFLINE_CANDIDATES.astro } },
{ include: "deno", style: { icon: OFFLINE_CANDIDATES.deno } },
{ include: "ruby", style: { icon: OFFLINE_CANDIDATES.ruby } },
{ include: "kotlin", style: { icon: OFFLINE_CANDIDATES.kotlin } },
{ include: "swift", style: { icon: OFFLINE_CANDIDATES.swift } },
{ include: "zig", style: { icon: OFFLINE_CANDIDATES.zig } },
{ include: "java", style: { icon: OFFLINE_CANDIDATES.java } },
{ include: "php", style: { icon: OFFLINE_CANDIDATES.php } },
{ include: "csharp", style: { icon: OFFLINE_CANDIDATES.csharp } },
{ include: "postgres", style: { icon: OFFLINE_CANDIDATES.pgsql } },
{ include: "mysql", style: { icon: OFFLINE_CANDIDATES.mysql } },
{ include: "docker", style: { icon: OFFLINE_CANDIDATES.docker } },
{ include: "readme", style: { icon: OFFLINE_CANDIDATES.docs } },
{ include: "changelog", style: { icon: OFFLINE_CANDIDATES.docs } },
{ include: "license", style: { icon: "vscode-icons:file-type-license" } },
{ include: "security", style: { icon: OFFLINE_CANDIDATES.security } },
{ include: ".lock", style: { icon: OFFLINE_CANDIDATES.lock } }
];
function toStyleRecord(source: Record<string, string>): Record<string, OfflineIconStyle> {
const entries = Object.entries(source).map(([key, icon]) => [key.toLowerCase(), { icon }] as const);
return Object.fromEntries(entries);
}
const OFFLINE_VUEPRESS_NAMED_STYLES = toStyleRecord({
...VUEPRESS_FILE_ICON_RULES.named,
...VUEPRESS_FILE_ICON_RULES.files
});
const OFFLINE_VUEPRESS_FOLDER_STYLES = toStyleRecord(VUEPRESS_FILE_ICON_RULES.folders);
const OFFLINE_VUEPRESS_EXTENSION_STYLES = toStyleRecord(VUEPRESS_FILE_ICON_RULES.extensions);
const OFFLINE_VUEPRESS_PARTIAL_STYLES: Array<{ include: string; style: OfflineIconStyle }> = Object.entries(
VUEPRESS_FILE_ICON_RULES.partials
).map(([include, icon]) => ({
include: include.toLowerCase(),
style: { icon }
}));
function pickBaseName(value: string): string {
const normalized = value.replace(/\\/g, "/");
const segment = normalized.split("/").pop();
return segment ?? normalized;
}
function getExtensionCandidates(baseName: string): string[] {
const candidates: string[] = [];
let extension = baseName;
const firstDotIndex = extension.indexOf(".");
if (firstDotIndex === -1) {
return candidates;
}
extension = extension.slice(firstDotIndex);
while (extension !== "") {
candidates.push(extension);
const nextDotIndex = extension.indexOf(".", 1);
if (nextDotIndex === -1) {
break;
}
extension = extension.slice(nextDotIndex);
}
return candidates;
}
function resolveFirstIconifyName(icon: string | readonly string[] | undefined): string | undefined {
if (!icon) {
return undefined;
}
if (typeof icon === "string") {
const normalized = normalizeIconifyId(icon);
return normalized.includes(":") ? normalized : undefined;
}
if (Array.isArray(icon)) {
for (const candidate of icon) {
const normalized = normalizeIconifyId(candidate);
if (normalized.includes(":")) {
return normalized;
}
}
return undefined;
}
return undefined;
}
function resolveIconifyIdWithFallback(icon: string | readonly string[] | undefined, fallback: string): string | undefined {
return resolveFirstIconifyName(icon) ?? resolveFirstIconifyName(fallback);
}
export function resolveOnlineIconifyId(fileName: string, nodeType: "folder" | "file", expanded: boolean): string | undefined {
const normalizedPath = fileName.replace(/\\/g, "/").toLowerCase();
const baseName = pick
… (demo build 截断)import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parseAllBlocks, parseCollapseRawContent, parseTabsRawContent } from "./parser";
const __dirname = dirname(fileURLToPath(import.meta.url));
const complexPath = join(__dirname, "__fixtures__", "plume-complex-test.md");
function contentIsOnlyBlocks(
content: string,
blocks: ReturnType<typeof parseAllBlocks>
): boolean {
const lines = content.split(/\r?\n/);
const inBlock = (i: number): boolean =>
blocks.some((b) => i >= b.startLine && i <= b.endLine);
for (let i = 0; i < lines.length; i++) {
if (!lines[i].trim()) continue;
if (inBlock(i)) continue;
return false;
}
return true;
}
describe("parser", () => {
it("parses card-grid → card → collapse → code-tabs from plume-complex-test §3.2", () => {
const complex = readFileSync(complexPath, "utf8");
const grid = parseAllBlocks(complex).find(
(b) => b.type === "card-grid" && b.rawContent.includes("Backend")
);
expect(grid).toBeDefined();
const card = parseAllBlocks(grid!.rawContent).find((b) => b.type === "card");
expect(card).toBeDefined();
const collapse = parseAllBlocks(card!.rawContent).find((b) => b.type === "collapse");
expect(collapse).toBeDefined();
const { preamble, items: collapseItems } = parseCollapseRawContent(collapse!.rawContent);
expect(collapseItems).toHaveLength(2);
expect(preamble.trim()).toBe("");
const apiBody = collapseItems[0].body;
const blocks0 = parseAllBlocks(apiBody);
expect(blocks0).toHaveLength(1);
expect(blocks0[0].type).toBe("code-tabs");
expect(contentIsOnlyBlocks(apiBody, blocks0)).toBe(true);
const tabs = parseTabsRawContent(blocks0[0].rawContent);
expect(tabs).toHaveLength(2);
expect(tabs[0].title).toBe("GET");
expect(tabs[1].title).toBe("POST");
});
it("parses collapse title without blank line before body", () => {
const noBlank = `- API
::: code-tabs
@tab A
\`\`\`js
1
\`\`\`
:::`;
const nbItem = parseCollapseRawContent(noBlank).items[0];
expect(nbItem.body).toContain("code-tabs");
});
it("parses ::: tabs id=\"...\" attribute form", () => {
const md = `::: tabs id="install-tabs"
@tab npm
hi
:::`;
const block = parseAllBlocks(md).find((b) => b.type === "tabs");
expect(block).toBeDefined();
expect((block!.attrs as { id?: string }).id).toBe("install-tabs");
});
it("parses Plume containers opened with four or more colons", () => {
const md = `:::: file-tree title="Tree"
- docs/
::::
:::: code-tree title="Code" entry="src/main.ts"
\`\`\`ts title="src/main.ts"
export const ok = true
\`\`\`
::::
:::: tabs#pm
@tab npm
npm install
::::
:::: code-tabs#api
@tab GET
\`\`\`ts
fetch("/api")
\`\`\`
::::`;
const blocks = parseAllBlocks(md);
expect(blocks.map((b) => b.type)).toEqual([
"file-tree",
"code-tree",
"tabs",
"code-tabs"
]);
expect(blocks.map((b) => b.markerLen)).toEqual([4, 4, 4, 4]);
expect((blocks[0].attrs as { title?: string }).title).toBe("Tree");
expect((blocks[1].attrs as { title?: string }).title).toBe("Code");
expect((blocks[2].attrs as { id?: string }).id).toBe("pm");
expect((blocks[3].attrs as { id?: string }).id).toBe("api");
});
it("keeps shorter nested container fences inside a longer outer container", () => {
const md = `::::: card-grid cols="2"
::: card title="A"
body
:::
::: card title="B"
body
:::
:::::`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
expect(grid.markerLen).toBe(5);
const cards = parseAllBlocks(grid.rawContent).filter((b) => b.type === "card");
expect(cards).toHaveLength(2);
expect(contentIsOnlyBlocks(grid.rawContent, cards)).toBe(true);
});
it("parses card-grid nested card icon attrs", () => {
const md = `:::: card-grid
::: card title="卡片标题 1" icon="smile"
content one
:::
::: card title="卡片标题 2" icon="sparkles"
content two
:::
::::`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
const cards = parseAllBlocks(grid!.rawContent).filter((b) => b.type === "card");
expect(cards).toHaveLength(2);
expect((cards[0].attrs as { icon?: string }).icon).toBe("smile");
expect((cards[1].attrs as { icon?: string }).icon).toBe("sparkles");
expect(contentIsOnlyBlocks(grid!.rawContent, cards)).toBe(true);
});
it("parses collapse preamble before list items", () => {
const withIntro = `Intro paragraph.
- Panel A
text a
`;
const parsedIntro = parseCollapseRawContent(withIntro);
expect(parsedIntro.preamble).toContain("Intro");
expect(parsedIntro.items).toHaveLength(1);
});
it("parses Vue component card grid syntax", () => {
const md = `<CardGrid cols="2">
<Card title="One" icon="smile">
Card body.
</Card>
<RepoCard repo="vuepress/core" fullname />
</CardGrid>`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
expect((grid!.attrs as { cols?: string }).cols).toBe("2");
const children = parseAllBlocks(grid!.rawContent);
expect(children.map((b) => b.type)).toEqual(["card", "repo-card"]);
expect((children[0].attrs as { title?: string }).title).toBe("One");
expect((children[1].attrs as { repo?: string; fullname?: boolean }).repo).toBe("vuepress/core");
expect((children[1].attrs as { fullname?: boolean }).fullname).toBe(true);
});
it("parses Vue LinkCard body closed on the same line", () => {
const md = `<LinkCard title="Docs" href="https://example.com">
**Rich** description</LinkCard>`;
const block = parseAllBlocks(md)[0];
expect(block?.type).toBe("link-card");
expect(block.rawContent).toBe(" **Rich** description");
});
});
import type {
CardContainerAttrs,
CardGridContainerAttrs,
CardMasonryContainerAttrs,
RepoCardContainerAttrs,
LinkCardContainerAttrs,
ImageCardContainerAttrs,
FieldContainerAttrs,
FieldGroupContainerAttrs,
FlexContainerAttrs,
AlignContainerAttrs,
WindowContainerAttrs,
ChatContainerAttrs,
CollapseContainerAttrs,
CodeTreeContainerAttrs,
CodeTreeFileItem,
FileTreeContainerAttrs,
FileTreeIconMode,
FileTreeNode,
FileTreeNodeProps,
ParsedBlock,
PromptContainerAttrs,
TabItem,
TabsContainerAttrs,
CodeTabsContainerAttrs,
TimelineContainerAttrs,
TimelineLineStyle,
TimelinePlacement,
AlignContainerType
} from "./types";
const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/;
const ELLIPSIS = "\u2026";
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_TAB_MARKER = /^\s*@tab(?::active)?\s*(.*)$/i;
// HTML 组件标签正则
type HtmlComponentTag = "Card" | "CardGrid" | "CardMasonry" | "RepoCard" | "LinkCard" | "ImageCard";
interface HtmlComponentOpen {
attrs: string;
afterOpen: string;
selfClosing: boolean;
}
interface HtmlComponentBlock {
rawContent: string;
endLine: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function matchHtmlComponentOpen(line: string, tag: HtmlComponentTag): HtmlComponentOpen | null {
const name = escapeRegExp(tag);
const selfClosing = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)\\/?>\\s*$`, "i"));
if (selfClosing && /\/>\s*$/.test(line)) {
return {
attrs: selfClosing[1] ?? "",
afterOpen: "",
selfClosing: true
};
}
const open = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)>(.*)$`, "i"));
if (!open) {
return null;
}
return {
attrs: open[1] ?? "",
afterOpen: open[2] ?? "",
selfClosing: false
};
}
function splitAtHtmlComponentClose(line: string, tag: HtmlComponentTag): { before: string } | null {
const match = line.match(new RegExp(`^(.*?)<\\/${escapeRegExp(tag)}>\\s*$`, "i"));
if (!match) {
return null;
}
return { before: match[1] ?? "" };
}
function collectHtmlComponentBlock(
lines: string[],
startLine: number,
tag: HtmlComponentTag,
open: HtmlComponentOpen
): HtmlComponentBlock | null {
if (open.selfClosing) {
return { rawContent: "", endLine: startLine };
}
const content: string[] = [];
const firstClose = splitAtHtmlComponentClose(open.afterOpen, tag);
if (firstClose) {
if (firstClose.before.trim()) {
content.push(firstClose.before);
}
return { rawContent: content.join("\n"), endLine: startLine };
}
if (open.afterOpen.trim()) {
content.push(open.afterOpen);
}
let fenceChar = "";
let fenceLength = 0;
let sameTagDepth = 0;
for (let cursor = startLine + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
if (fenceLength > 0) {
content.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = current.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
content.push(current);
continue;
}
const nestedOpen = matchHtmlComponentOpen(current, tag);
if (nestedOpen && !nestedOpen.selfClosing) {
sameTagDepth += 1;
content.push(current);
continue;
}
const close = splitAtHtmlComponentClose(current, tag);
if (close) {
if (sameTagDepth === 0) {
if (close.before.trim()) {
content.push(close.before);
}
return { rawContent: content.join("\n"), endLine: cursor };
}
sameTagDepth -= 1;
content.push(current);
continue;
}
content.push(current);
}
return null;
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
function parseLinkCardAttrs(attrsStr: string): LinkCardContainerAttrs {
const attrs: LinkCardContainerAttrs = { href: "" };
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const target = parseAttrValue(attrsStr, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(attrsStr, "rel");
if (rel) attrs.rel = rel;
return attrs;
}
function parseCardAttrs(attrsStr: string): CardContainerAttrs {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
return attrs;
}
function parseCardGridAttrs(attrsStr: string): CardGridContainerAttrs {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
return attrs;
}
function parseCardMasonryAttrs(attrsStr: string): CardMasonryContainerAttrs {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(attrsStr, "gap");
if (gap) attrs.gap = gap;
return attrs;
}
function parseRepoCardAttrs(attrsStr: string): RepoCardContainerAttrs | null {
const repo = parseAttrValue(attrsStr, "repo");
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(attrsStr, "provider");
if (provider === "github" || provider === "gitee") attrs.provider = provider;
if (/(^|\s)fullname(?:\s|=|$)/i.test(attrsStr)) {
const fullname = parseAttrValue(attrsStr, "fullname");
attrs.fullname = fullname ? fullname !== "false" : true;
}
return attrs;
}
function parseImageCardAttrs(attrsStr: string): ImageCardContainerAttrs {
const attrs: ImageCardContainerAttrs = { image: "" };
const image = parseAttrValue(attrsStr, "image");
if (image) attrs.image = image;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const author = parseAttrValue(attrsStr, "author");
if (author) attrs.author = author;
const date = parseAttrValue(attrsStr, "date");
if (date) attrs.date = date;
const width = parseAttrValue(attrsStr, "width");
if (width) attrs.width = width;
const center = parseAttrValue(attrsStr, "center");
if (center !== undefined) attrs.center = center !== "false";
else if (/(^|\s)center(\s|$)/.test(attrsStr)) attrs.center = true;
return attrs;
}
export function normalizeCodeTreePath(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.replace(/^\/+/, "");
}
function removeEndingSlash(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
}
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
const trimmed = content.trimEnd();
if (!trimmed) {
return [];
}
const lines = trimmed.split(/\r?\n/);
const root: FileTreeNode = {
filename: "",
type: "folder",
expanded: true,
level: -1,
children: []
};
const stack: FileTreeNode[] = [root];
const initialIndent = lines[0]?.match(/^\s*/)?.[0].length ?? 0;
for (const line of lines) {
const match = line.match(/^(\s*)-(.*)$/);
if (!match) {
continue;
}
const level = Math.floor((match[1].length - initialIndent) / 2);
const info = match[2].trim();
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
const parent = stack[stack.length - 1];
if (!parent) {
continue;
}
const node: FileTreeNode = {
level,
children: [],
...parseFileTreeNodeInfo(info)
};
parent.children.push(node);
stack.push(node);
}
return root.children;
}
export function parseFileTreeNodeInfo(info: string): FileTreeNodeProps {
let filename = "";
let comment = "";
let focus = false;
let expanded: boolean | undefined = true;
let type: "folder" | "file" = "file";
let diff: "add" | "remove" | undefined;
if (info.startsWith("++")) {
info = info.slice(2).trim();
diff = "add";
} else if (info.startsWith("--")) {
info = info.slice(2).trim();
diff = "remove";
}
info = info.replace(RE_FOCUS, (_matched, focusName: string) => {
filename = focusName;
focus = true;
return "";
});
if (filename === "" && !focus) {
const commentStart = info.indexOf("#");
filename = info.slice(0, commentStart === -1 ? info.length : commentStart).trim();
info = commentStart === -1 ? "" : info.slice(commentStart);
}
comment = info.trim();
if (filename.endsWith("/")) {
type = "folder";
expanded = false;
filename = removeEndingSlash(filename);
}
return {
filename,
comment,
focus,
expanded,
type,
diff
};
}
export function parseContainerHeader(line: string, fallbackIcon: FileTreeIconMode): FileTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*file-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: FileTreeContainerAttrs = {
icon: fallbackIcon
};
const attrRegex = /([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g;
let attrMatch: RegExpExecArray | null;
while ((attrMatch = attrRegex.exec(tail)) !== null) {
const key = attrMatch[1];
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (key === "title") {
attrs.title = value;
}
if (key === "icon" && (value === "simple" || value === "colored")) {
attrs.icon = value;
}
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseCodeTreeContainerHeader(line: string, fallbackIcon: FileTreeIconMode): CodeTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: CodeTreeContainerAttrs = {
icon: fallbackIcon
};
const title = parseAttrValue(tail, "title");
if (title) {
attrs.title = title;
}
const entry = parseAttrValue(tail, "entry");
if (entry) {
attrs.entry = normalizeCodeTreePath(entry);
}
const height = parseAttrValue(tail, "height");
if (height) {
attrs.height = height;
}
const icon = parseAttrValue(tail, "icon");
if (icon === "simple" || icon === "colored") {
attrs.icon = icon;
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseTabsContainerHeader(line: string): TabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*tabs\b(.*)$/i);
if (!match) {
return null;
}
const tail = (match[1] ?? "").trim();
const attrs: TabsContainerAttrs = {};
const idMatch = tail.match(/^#([^\s#]+)/);
if (idMatch?.[1]) {
attrs.id = idMatch[1];
} else {
const idAttr = parseAttrValue(tail, "id");
if (idAttr) {
attrs.id = idAttr;
}
}
return attrs;
}
export function isFileTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*file-tree\b/i.test(text.trim());
}
export function isCodeTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tree\b/i.test(text.trim());
}
export function isTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*tabs\b/i.test(text.trim());
}
export function parseCodeTabsContainerHeader(line: string): CodeTabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tabs\b(.*)$/i);
if (!match) {
return null;
}
const rest = (match[1] ?? "").trim();
const attrs: CodeTabsContainerAttrs = {};
// Syntax: ::: code-tabs#myid (hash form, matching original vuepress-theme-plume)
const hashMatch = rest.match(/^#([\w-]+)/);
if (hashMatch) {
attrs.id = hashMatch[1];
} else {
// Fallback: id="..." form for parity with other containers.
const id = parseAttrValue(rest, "id");
if (id) attrs.id = id;
}
return attrs;
}
export function isCodeTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tabs\b/i.test(text.trim());
}
export function isStepsOpenMarker(text: string): boolean {
return /^:{3,}\s*steps\b/i.test(text.trim());
}
export interface ParsedStepItem {
/** Markdown body of the step (title line + content), without the leading `N.` marker */
body: string;
}
const RE_STEP_LINE = /^\s*\d+[.)]\s+/;
/**
* Split steps container body into items. VuePress relies on markdown `ol`, but
* Obsidian breaks lists when `:::` containers appear inside `li` — we render
* one `<li>` per step and run markdown inside each item instead.
*/
export function parseStepsRawContent(rawContent: string): ParsedStepItem[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const lines = text.split(/\r?\n/);
const chunks: string[] = [];
let current: string[] = [];
const pushChunk = (): void => {
if (current.length === 0) {
return;
}
chunks.push(current.join("\n"));
current = [];
};
for (const line of lines) {
if (RE_STEP_LINE.test(line)) {
pushChunk();
current.push(line);
continue;
}
if (current.length > 0) {
current.push(line);
}
}
pushChunk();
const items: ParsedStepItem[] = [];
for (const chunk of chunks) {
const chunkLines = chunk.split(/\r?\n/);
if (chunkLines.length === 0) {
continue;
}
chunkLines[0] = chunkLines[0].replace(/^\s*\d+[.)]\s*/, "");
const body = chunkLines.join("\n").trim();
items.push({ body });
}
return items;
}
/**
* Remove common list-item indentation so fenced ``` inside steps parse correctly
* in Obsidian (indented fences are not recognized as code blocks).
*/
export function dedentStepBody(body: string): string {
const lines = body.split(/\r?\n/);
const positiveIndents: number[] = [];
for (const line of lines) {
if (!line.trim()) {
continue;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len > 0) {
positiveIndents.push(len);
}
}
if (positiveIndents.length === 0) {
return body;
}
const min = Math.min(...positiveIndents);
return lines
.map((line) => {
if (!line.trim()) {
return line;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len >= min) {
return line.slice(min);
}
return line;
})
.join("\n");
}
export interface CollapseItem {
titleLines: string[];
body: string;
expand?: boolean;
}
export interface ParsedCollapseContent {
/** Markdown before the first list item (optional intro). */
preamble: string;
items: CollapseItem[];
}
function buildCollapseItem(rawLines: string[]): CollapseItem {
while (rawLines.length && rawLines[0].trim() === "") rawLines.shift();
while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop();
if (rawLines.length === 0) {
return { titleLines: [], body: "", expand: undefined };
}
const titleLines = [rawLines[0]];
let bodyStart = 1;
// 允许空行后正文(正文不缩进也能识别)
while (bodyStart < rawLines.length && rawLines[bodyStart].trim() === "") {
bodyStart += 1;
}
// 如果正文首行不是新列表项,则全部视为正文
let bodyRaw = "";
if (bodyStart < rawLines.length) {
bodyRaw = rawLines.slice(bodyStart).join("\n");
}
let expand: boolean | undefined;
titleLines[0] = titleLines[0].replace(/^:([+-])\s*/, (_, flag: string) => {
expand = flag === "+";
return "";
});
return {
titleLines,
body: dedentStepBody(bodyRaw),
expand
};
}
/**
* Parse `::: collapse` list body into optional preamble + panel items.
*/
export function parseCollapseRawContent(rawContent: string): ParsedCollapseContent {
const lines = rawContent.replace(/\r\n/g, "\n").split("\n");
const preambleLines: string[] = [];
const items: CollapseItem[] = [];
let current: string[] | null = null;
const itemStart = /^(?:[-*+]\s+|\d+[.)]\s+)/;
for (const line of lines) {
if (itemStart.test(line)) {
if (current) {
items.push(buildCollapseItem(current));
}
current = [line.replace(itemStart, "")];
continue;
}
if (current) {
current.push(line);
} else {
preambleLines.push(line);
}
}
if (current) {
items.push(buildCollapseItem(current));
}
const filtered = items.filter(
(item) => item.titleLines.length > 0 || item.body.trim().length > 0
);
const preamble = dedentStepBody(preambleLines.join("\n").replace(/^\n+|\n+$/g, ""));
if (filtered.length > 0) {
return { preamble, items: filtered };
}
const trimmed = rawContent.replace(/^\n+|\n+$/g, "");
if (!trimmed) {
return { preamble: "", items: [] };
}
return {
preamble: "",
items: [{ titleLines: [], body: dedentStepBody(trimmed) }]
};
}
/** Split flex body into separate block-level segments (e.g. two tables). */
export function splitFlexSegments(rawContent: string): string[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const parts = text
.split(/\n(?:[ \t]*\n)+/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length > 0 ? parts : [text];
}
/** Parse flex header flags the same way as vuepress-plugin-md-power alignPlugin. */
export function parseFlexContainerAttrs(rest: string): FlexContainerAttrs {
const attrs: FlexContainerAttrs = {};
const gap = parseAttrValue(rest, "gap");
if (gap) {
attrs.gap = gap;
}
const flagSource = rest
.replace(/gap\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi, " ")
.trim()
.toLowerCase();
const flags = flagSource.split(/\s+/).filter(Boolean);
for (const flag of flags) {
if (flag === "start") {
attrs.align = "start";
} else if (flag === "end") {
attrs.align = "end";
} else if (flag === "center") {
attrs.align = "center";
} else if (flag === "between") {
attrs.justify = "between";
} else if (flag === "around") {
attrs.justify = "around";
} else if (flag === "column") {
attrs.column = true;
} else if (flag === "wrap") {
attrs.wrap = true;
}
}
if (flags.includes("center") && !attrs.justify) {
attrs.justify = "center";
}
return attrs;
}
export function parsePromptContainerHeader(line: string): (PromptContainerAttrs & { markerLen: number }) | null {
const match = line.trim().match(/^(:{3,})\s*(note|info|tip|warning|caution|details|important)\b(.*)$/i);
if (!match) {
return null;
}
const markerLen = match[1]?.length ?? 0;
const type = (match[2] ?? "").toLowerCase() as PromptContainerAttrs["type"];
const title = (match[3] ?? "").trim() || undefined;
return {
type,
title,
markerLen
};
}
export function isPromptContainerOpenMarker(text: string): boolean {
return /^:{3,}\s*(note|info|tip|warning|caution|details|important)\b/i.test(text.trim());
}
export function isFileTreeCloseMarker(text: string): boolean {
return text.trim() === ":::";
}
export function parseCodeTreeRawContent(content: string): CodeTreeFileItem[] {
const lines = content.split(/\r?\n/);
const files: CodeTreeFileItem[] = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const openMatch = line.match(RE_CODE_FENCE_OPEN);
if (!openMatch) {
continue;
}
const fence = openMatch[2];
const markerChar = fence[0];
const markerLength = fence.length;
const info = (openMatch[3] ?? "").trim();
const title = parseAttrValue(info, "title");
const isActive = /(?:^|\s):active(?:\s|$)/.test(info);
const languageToken = info.split(/\s+/)[0] ?? "";
const language = languageToken && !languageToken.startsWith(":") ? languageToken : "text";
const body: string[] = [];
let closed = false;
for (let cursor = lineIndex + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
const closeRegex = new RegExp(`^\\s*${markerChar}{${markerLength},}\\s*$`);
if (closeRegex.test(current)) {
lineIndex = cursor;
closed = true;
break;
}
body.push(current);
}
if (!closed) {
break;
}
if (!title) {
continue;
}
const filepath = normalizeCodeTreePath(title);
if (!filepath) {
continue;
}
files.push({
filepath,
language,
content: body.join("\n"),
active: isActive
});
}
return files;
}
function parseTabMarker(line: string): {
title: string;
value: string;
active: boolean;
} | null {
const match = line.match(RE_TAB_MARKER);
if (!match) {
return null;
}
const active = /@tab:active/i.test(line);
const raw = (match[1] ?? "").trim();
const hashIndex = raw.indexOf("#");
let title = raw;
let value = "";
if (hashIndex >= 0) {
title = raw.slice(0, hashIndex).trim();
value = raw.slice(hashIndex + 1).trim();
}
title ||= value;
value ||= title;
if (!title && !value) {
return null;
}
return {
title,
value,
active
};
}
export function parseTabsRawContent(content: string): TabItem[] {
const lines = content.split(/\r?\n/);
const tabs: TabItem[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const marker = parseTabMarker(lines[lineIndex]);
if (!marker) {
lineIndex += 1;
continue;
}
const body: string[] = [];
lineIndex += 1;
let fenceChar = "";
let fenceLength = 0;
while (lineIndex < lines.length) {
const current = lines[lineIndex];
if (fenceLength > 0) {
body.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
lineIndex += 1;
continue;
}
const openMatch = current.match(RE_CODE_FENCE_OPEN);
if (openMatch) {
const fence = openMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
body.push(current);
lineIndex += 1;
continue;
}
if (parseTabMarker(current)) {
break;
}
body.push(current);
lineIndex += 1;
}
tabs.push({
title: marker.title,
value: marker.value,
active: marker.active,
content: body.join("\n").replace(/^\n+|\n+$/g, "")
});
}
for (let index = 0; index < tabs.length; index += 1) {
const tab = tabs[index];
if (!tab.title) {
tab.title = `Tab ${index + 1}`;
}
if (!tab.value) {
tab.value = tab.title;
}
}
return tabs;
}
export function parseCodeTreeFileNodes(files: CodeTreeFileItem[]): FileTreeNode[] {
const nodes: FileTreeNode[] = [];
for (const file of files) {
const normalized = normalizeCodeTreePath(file.filepath);
if (!normalized) {
continue;
}
const parts = normalized.split("/").filter(Boolean);
let children = nodes;
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const isFile = index === parts.length - 1;
let node = children.find((item) => {
return item.filename === part;
});
if (!node) {
node = {
filename: part,
filepath: isFile ? normalized : undefined,
type: isFile ? "file" : "folder",
expanded: true,
level: index,
children: []
};
children.push(node);
}
if (isFile) {
node.type = "file";
node.filepath = normalized;
continue;
}
node.type = "folder";
node.expanded = true;
children = node.children;
}
}
return nodes;
}
function listItemInlineText(item: HTMLLIElement): string {
const parts: string[] = [];
for (const node of Array.from(item.childNodes)) {
if (node instanceof HTMLElement && (node.tagName === "UL" || node.tagName === "OL")) {
break;
}
if (node instanceof HTMLElement && node.tagName === "STRONG") {
const strongText = (node.textContent ?? "").trim();
parts.push(`**${strongText}**`);
continue;
}
parts.push(node.textContent ?? "");
}
return parts.join("").replace(/\r?\n/g, " ").trim();
}
export function listElementToRawLines(list: HTMLElement, level = 0): string[] {
const lines: string[] = [];
for (const child of Array.from(list.children)) {
if (!(child instanceof HTMLLIElement)) {
continue;
}
const info = listItemInlineText(child);
if (info) {
lines.push(`${" ".repeat(level)}- ${info}`);
}
const nestedLists = Array.from(child.children).filter((nested) => {
return nested.tagName === "UL" || nested.tagName === "OL";
});
for (const nestedList of nestedLists) {
lines.push(...listElementToRawLines(nestedList as HTMLElement, level + 1));
}
}
return lines;
}
export function fileTreeToCMDText(nodes: FileTreeNode[], prefix = ""): string {
let content = prefix ? "" : ".\n";
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const lead = i === nodes.length - 1 ? "└── " : "├── ";
content += `${prefix}${lead}${node.filename}\n`;
const childNodes = node.children.filter((child) => {
return child.filename !== ELLIPSIS && child.filename !== "...";
});
if (childNodes.length > 0) {
const childPrefix = prefix + (i === nodes.length - 1 ? " " : "│ ");
content += fileTreeToCMDText(childNodes, childPrefix);
}
}
return content;
}
// ---------------------------------------------------------------------------
// Unified block scanner
// ---------------------------------------------------------------------------
const RE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_DEFAULT_ICON_FALLBACK: FileTreeIconMode = "colored";
const CODE_TREE_EMBED_RE_LINE = /^\s*@\[code-tree([^\]]*)\]\(([^)]*)\)\s*$/i;
interface ContainerHeaderInfo {
type: "file-tree" | "code-tree" | "tabs" | "code-tabs" | "steps" | "prompt" | "collapse" | "card" | "card-grid" | "card-masonry" | "repo-card" | "link-card" | "image-card" | "field" | "field-group" | "flex" | "align" | "window" | "chat" | "timeline";
markerLen: number;
attrs:
| FileTreeContainerAttrs
| CodeTreeContainerAttrs
| TabsContainerAttrs
| CodeTabsContainerAttrs
| PromptContainerAttrs
| CollapseContainerAttrs
| CardContainerAttrs
| CardGridContainerAttrs
| CardMasonryContainerAttrs
| RepoCardContainerAttrs
| LinkCardContainerAttrs
| ImageCardContainerAttrs
| FieldContainerAttrs
| FieldGroupContainerAttrs
| FlexContainerAttrs
| AlignContainerAttrs
| WindowContainerAttrs
| ChatContainerAttrs
| TimelineContainerAttrs;
}
function detectContainerOpen(line: string, fallbackIcon: FileTreeIconMode): ContainerHeaderInfo | null {
const trimmed = line.trim();
const match = trimmed.match(/^(:{3,})\s*([a-zA-Z][\w-]*)\b(.*)$/);
if (!match) {
return null;
}
const markerLen = match[1].length;
const keyword = match[2].toLowerCase();
const rest = match[3] ?? "";
if (keyword === "file-tree") {
const attrs = parseContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "file-tree", markerLen, attrs };
}
if (keyword === "code-tree") {
const attrs = parseCodeTreeContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "code-tree", markerLen, attrs };
}
if (keyword === "tabs") {
const attrs = parseTabsContainerHeader(line);
if (!attrs) return null;
return { type: "tabs", markerLen, attrs };
}
if (keyword === "code-tabs") {
const attrs = parseCodeTabsContainerHeader(line);
if (!attrs) return null;
return { type: "code-tabs", markerLen, attrs };
}
if (keyword === "steps") {
return { type: "steps", markerLen, attrs: {} as TabsContainerAttrs };
}
if (keyword === "collapse") {
const attrs: CollapseContainerAttrs = {};
if (/(^|\s)accordion(\s|$|=)/i.test(rest)) {
const accordionVal = parseAttrValue(rest, "accordion");
attrs.accordion = accordionVal ? accordionVal !== "false" : true;
}
if (/(^|\s)expand(\s|$|=)/i.test(rest)) {
const expandVal = parseAttrValue(rest, "expand");
attrs.expand = expandVal ? expandVal !== "false" : true;
}
return { type: "collapse", markerLen, attrs };
}
if (keyword === "card") {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
return { type: "card", markerLen, attrs };
}
if (keyword === "card-grid") {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
return { type: "card-grid", markerLen, attrs };
}
if (keyword === "card-masonry") {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(rest, "gap");
if (gap) attrs.gap = gap;
return { type: "card-masonry", markerLen, attrs };
}
if (keyword === "repo-card") {
// Accept either `repo="owner/name"` or a positional `owner/name` after
// the keyword (matches the convention used by `prompt` containers).
let repo = parseAttrValue(rest, "repo") ?? "";
if (!repo) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && positional.includes("/") && !positional.includes("=")) {
repo = positional;
}
}
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(rest, "provider");
if (provider === "gitee" || provider === "github") attrs.provider = provider;
if (/(^|\s)fullname(\s|$|=)/i.test(rest)) {
const v = parseAttrValue(rest, "fullname");
attrs.fullname = v ? v !== "false" : true;
}
return { type: "repo-card", markerLen, attrs };
}
if (keyword === "link-card") {
// href is required and supports either `href="..."` or a positional URL
// after the keyword (matches the `repo-card` convention).
let href = parseAttrValue(rest, "href") ?? "";
if (!href) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && !positional.includes("=")) href = positional;
}
if (!href) return null;
const attrs: LinkCardContainerAttrs = { href };
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(rest, "description");
if (description) attrs.description = description;
const target = parseAttrValue(rest, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(rest, "rel");
if (rel) attrs.rel = rel;
return { type: "link-card", markerLen, attrs
… (demo build 截断)import { App, MarkdownView, TFile, setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import { scanCodeFences, decorateCodeBlockTitles } from "../render";
import type { FileTreeIconMode } from "../types";
import { prepareIconifyIconElement, processIconifyIcons } from "../render/iconify-online";
/**
* Obsidian treats fenced-code info strings (e.g. title="foo") as cosmetic and
* does not re-run post-processors when only those change. This service tracks
* title signatures per file and patches or forces preview rebuilds.
*/
export class CodeFenceTitleService {
private lastTitleSig = new Map<string, string>();
private dirtyPreviewFiles = new Set<string>();
constructor(
private readonly app: App,
private getDefaultIconMode: () => FileTreeIconMode
) {}
seedBaseline(file: TFile, text: string): void {
const sig = this.buildTitleSignature(text);
if (!this.lastTitleSig.has(file.path)) {
this.lastTitleSig.set(file.path, sig);
}
}
reconcileWithText(file: TFile, text: string): void {
const fences = scanCodeFences(text).filter((f) => !!f.title);
const sig = JSON.stringify(fences.map((f) => f.title ?? ""));
const prevSig = this.lastTitleSig.get(file.path);
const titlesChanged = prevSig !== undefined && prevSig !== sig;
this.lastTitleSig.set(file.path, sig);
if (titlesChanged) {
this.dirtyPreviewFiles.add(file.path);
}
if (fences.length === 0) {
this.stripTitleWrappersForFile(file);
if (titlesChanged) {
this.refreshDirtyPreviews();
}
return;
}
this.patchTitleWrappersForFile(file, fences);
if (titlesChanged) {
this.refreshDirtyPreviews();
}
}
decorateSection(
rootElement: HTMLElement,
fileText: string,
lineStart: number,
lineEnd: number
): void {
const fences = scanCodeFences(fileText).filter(
(f) => f.openLine >= lineStart && f.openLine <= lineEnd
);
if (fences.length === 0) {
return;
}
decorateCodeBlockTitles(rootElement, fences, this.getDefaultIconMode());
}
refreshDirtyPreviews(): void {
if (this.dirtyPreviewFiles.size === 0) {
return;
}
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
continue;
}
const path = view.file?.path;
if (!path || !this.dirtyPreviewFiles.has(path)) {
continue;
}
if (view.getMode?.() !== "preview") {
continue;
}
try {
view.previewMode.rerender(true);
this.dirtyPreviewFiles.delete(path);
} catch (err) {
console.error("[theme-plume] preview rerender failed", err);
}
}
}
clear(): void {
this.lastTitleSig.clear();
this.dirtyPreviewFiles.clear();
}
private buildTitleSignature(text: string): string {
const fences = scanCodeFences(text).filter((f) => !!f.title);
return JSON.stringify(fences.map((f) => f.title ?? ""));
}
private stripTitleWrappersForFile(file: TFile): void {
this.forEachPreviewOfFile(file, (preview) => {
for (const wrapper of Array.from(
preview.querySelectorAll<HTMLElement>(".vp-code-block-title")
)) {
const pre = wrapper.querySelector("pre");
if (pre) {
wrapper.replaceWith(pre);
pre.removeAttribute("data-vp-code-title-done");
}
}
});
}
private patchTitleWrappersForFile(
file: TFile,
fences: ReturnType<typeof scanCodeFences>
): void {
this.forEachPreviewOfFile(file, (preview) => {
const wrappers = Array.from(
preview.querySelectorAll<HTMLElement>(".vp-code-block-title")
);
if (wrappers.length !== fences.length) {
return;
}
for (let i = 0; i < wrappers.length; i += 1) {
const wrapper = wrappers[i];
const newTitle = fences[i].title as string;
if (wrapper.dataset.title === newTitle) {
continue;
}
wrapper.dataset.title = newTitle;
const label = wrapper.querySelector<HTMLElement>(".vp-code-block-title-text");
if (!label) {
continue;
}
while (label.firstChild) {
label.removeChild(label.firstChild);
}
const iconHost = document.createElement("span");
iconHost.className = "vp-code-block-title-icon ft-icon";
const desc = resolveNodeIcon(newTitle, "file", false, this.getDefaultIconMode());
if (desc.colorClass) {
iconHost.classList.add(desc.colorClass);
}
if (desc.iconifyId) {
prepareIconifyIconElement(iconHost, desc.iconifyId);
void processIconifyIcons(iconHost);
} else {
setIcon(iconHost, desc.icon);
}
label.appendChild(iconHost);
label.appendChild(document.createTextNode(newTitle));
}
});
}
private forEachPreviewOfFile(file: TFile, fn: (preview: HTMLElement) => void): void {
const seen = new Set<HTMLElement>();
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
continue;
}
if (view.file?.path !== file.path) {
continue;
}
const roots: Array<HTMLElement | undefined | null> = [
view.previewMode?.containerEl,
view.contentEl
];
for (const root of roots) {
if (!root || seen.has(root)) {
continue;
}
seen.add(root);
fn(root);
}
}
}
}
import type { MarkdownPostProcessorContext, Plugin } from "obsidian";
import { parseAllBlocks } from "../parser";
import { renderInnerMarkdown, type BlockRenderContext } from "../render";
import type { FileTreeIconMode, ParsedBlock } from "../types";
import { hashString } from "../utils/hash";
import { CodeFenceTitleService } from "./code-fence-titles";
const HIDDEN_SECTION_CLASS = "plume-section-absorbed";
export interface PreviewPipelineOptions {
plugin: Plugin;
getDefaultIconMode: () => FileTreeIconMode;
getOrParseBlocks: (text: string, sourcePath: string) => ParsedBlock[];
/** Prefer unsaved editor buffer over section snapshot (info.text). */
getDocumentText?: (sourcePath: string, sectionSnapshot: string) => string;
isDocumentDirty?: (sourcePath: string) => boolean;
clearDocumentDirty?: (sourcePath: string) => void;
buildRenderContext: (
sourcePath: string,
ctx: MarkdownPostProcessorContext
) => BlockRenderContext;
}
interface LeadingEntry {
el: HTMLElement;
ctx: MarkdownPostProcessorContext;
}
/**
* Coordinates Obsidian's per-section post-processor with Plume's block model.
*
* Strategy (stable, battle-tested in this codebase):
* 1. Parse blocks from the full file text (section info always carries full text).
* 2. Leading section (contains block open line) renders the whole block via
* placeholder-based `renderInnerMarkdown` (no fighting markdown-it).
* 3. Interior sections are visually absorbed (zero height, not display:none)
* so outline scroll positions stay usable.
* 4. Interior edits schedule a leading-section refresh.
*/
export class PreviewPipeline {
private leadingSections = new Map<string, LeadingEntry>();
private pendingReRender = new Set<string>();
readonly codeFenceTitles: CodeFenceTitleService;
constructor(private readonly options: PreviewPipelineOptions) {
this.codeFenceTitles = new CodeFenceTitleService(
options.plugin.app,
options.getDefaultIconMode
);
}
clear(): void {
this.leadingSections.clear();
this.pendingReRender.clear();
this.codeFenceTitles.clear();
}
/** Drop section skip keys so the next post-process pass rebuilds blocks (e.g. after save). */
invalidateBlocksForFile(sourcePath: string): void {
for (const [key, entry] of this.leadingSections) {
if (!key.startsWith(`${sourcePath}::`)) {
continue;
}
delete entry.el.dataset.plumeBlockKey;
}
}
/** Re-run leading-section renderers (e.g. while preview is visible and the file is edited). */
refreshLeadingSectionsForFile(sourcePath: string): void {
for (const [key, entry] of this.leadingSections) {
if (!key.startsWith(`${sourcePath}::`)) {
continue;
}
if (!entry.el.isConnected) {
this.leadingSections.delete(key);
continue;
}
if (this.pendingReRender.has(key)) {
continue;
}
this.pendingReRender.add(key);
queueMicrotask(() => {
this.pendingReRender.delete(key);
const fresh = this.leadingSections.get(key);
if (!fresh?.el.isConnected) {
return;
}
delete fresh.el.dataset.plumeBlockKey;
void this.processSection(fresh.el, fresh.ctx).catch((err) => {
console.error("[theme-plume] leading refresh failed", err);
});
});
}
}
async processSection(
rootElement: HTMLElement,
ctx: MarkdownPostProcessorContext
): Promise<void> {
const info = ctx.getSectionInfo(rootElement);
if (!info) {
this.unhideSection(rootElement);
return;
}
const docText = this.options.getDocumentText?.(ctx.sourcePath, info.text) ?? info.text;
const blocks = this.options.getOrParseBlocks(docText, ctx.sourcePath);
if (blocks.length === 0) {
this.unhideSection(rootElement);
this.codeFenceTitles.decorateSection(
rootElement,
info.text,
info.lineStart,
info.lineEnd
);
return;
}
const sectionStart = info.lineStart;
const sectionEnd = info.lineEnd;
const overlapping = blocks.filter(
(b) => b.endLine >= sectionStart && b.startLine <= sectionEnd
);
if (overlapping.length === 0) {
this.unhideSection(rootElement);
this.codeFenceTitles.decorateSection(
rootElement,
info.text,
info.lineStart,
info.lineEnd
);
return;
}
const interior = overlapping.find((b) => b.startLine < sectionStart);
if (interior) {
this.absorbSection(rootElement);
for (const b of overlapping) {
if (b.startLine < sectionStart) {
this.scheduleLeadingReRender(ctx.sourcePath, b.startLine, rootElement);
}
}
return;
}
const lines = docText.split(/\r?\n/);
let renderEnd = sectionEnd;
for (const b of overlapping) {
if (b.endLine > renderEnd) {
renderEnd = b.endLine;
}
}
const slice = lines.slice(sectionStart, renderEnd + 1).join("\n");
const lineKey = overlapping.map((b) => `${b.startLine}:${b.endLine}`).join("|");
const blocksKey = overlapping.map((b) => hashString(b.rawContent)).join("|");
const blockKey = `${lineKey}|${hashString(slice)}|${blocksKey}`;
const snapshotInSync = docText === info.text;
const isDirty = this.options.isDocumentDirty?.(ctx.sourcePath) ?? false;
// 强制重新渲染的条件:文档脏了、快照不同步、或块key不匹配
const shouldRerender = isDirty || !snapshotInSync || rootElement.dataset.plumeBlockKey !== blockKey || rootElement.childElementCount === 0;
if (!shouldRerender) {
return;
}
rootElement.empty();
this.unhideSection(rootElement);
rootElement.dataset.plumeBlockKey = blockKey;
rootElement.classList.add("plume-has-block");
const renderCtx = this.options.buildRenderContext(ctx.sourcePath, ctx);
for (const b of overlapping) {
if (b.startLine >= sectionStart) {
const key = `${ctx.sourcePath}::${b.startLine}`;
this.leadingSections.set(key, { el: rootElement, ctx });
}
}
try {
await renderInnerMarkdown(rootElement, slice, renderCtx);
this.options.clearDocumentDirty?.(ctx.sourcePath);
} catch (err) {
console.error("[theme-plume] section render failed", err);
const errEl = rootElement.createDiv({ cls: "plume-render-error" });
errEl.createEl("p", {
text: "Obsidian Plume: block render failed. See developer console for details."
});
errEl.createEl("pre", { text: slice });
}
this.scheduleRemeasure(rootElement);
}
private scheduleRemeasure(el: HTMLElement): void {
if (!el.querySelector(".vp-card-masonry, .vp-card-grid")) {
return;
}
window.requestAnimationFrame(() => {
if (!el.isConnected) {
return;
}
try {
this.options.plugin.app.workspace.trigger("resize");
} catch {
/* best-effort */
}
});
}
private scheduleLeadingReRender(
sourcePath: string,
blockStartLine: number,
triggerEl: HTMLElement
): void {
const key = `${sourcePath}::${blockStartLine}`;
const entry = this.leadingSections.get(key);
if (!entry) {
return;
}
if (!entry.el.isConnected) {
this.leadingSections.delete(key);
return;
}
if (entry.el === triggerEl) {
return;
}
if (this.pendingReRender.has(key)) {
return;
}
this.pendingReRender.add(key);
queueMicrotask(() => {
this.pendingReRender.delete(key);
const fresh = this.leadingSections.get(key);
if (!fresh || !fresh.el.isConnected) {
return;
}
delete fresh.el.dataset.plumeBlockKey;
void this.processSection(fresh.el, fresh.ctx).catch((err) => {
console.error("[theme-plume] leading re-render failed", err);
});
});
}
private absorbSection(el: HTMLElement): void {
el.empty();
el.classList.add(HIDDEN_SECTION_CLASS);
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
private unhideSection(el: HTMLElement): void {
if (el.classList.contains(HIDDEN_SECTION_CLASS)) {
el.classList.remove(HIDDEN_SECTION_CLASS);
}
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
}
import type { MarkdownView } from "obsidian";
/**
* Keeps editor buffer + dirty state; refreshes Plume blocks without previewMode.set/rerender.
* Avoids scroll jumps and flicker from full preview rebuilds.
*/
export class PreviewDocumentSync {
private readonly liveText = new Map<string, string>();
private readonly dirtyPaths = new Set<string>();
private readonly scrollByPath = new Map<string, number>();
setLiveText(sourcePath: string, text: string): void {
this.liveText.set(sourcePath, text);
}
markDirty(sourcePath: string, text: string): void {
this.liveText.set(sourcePath, text);
this.dirtyPaths.add(sourcePath);
}
isDirty(sourcePath: string): boolean {
return this.dirtyPaths.has(sourcePath);
}
clearDirty(sourcePath: string): void {
this.dirtyPaths.delete(sourcePath);
}
getLiveText(sourcePath: string, fallback: string): string {
return this.liveText.get(sourcePath) ?? fallback;
}
deleteLive(sourcePath: string): void {
this.liveText.delete(sourcePath);
this.dirtyPaths.delete(sourcePath);
this.scrollByPath.delete(sourcePath);
}
rememberScroll(sourcePath: string, scrollY: number): void {
if (scrollY > 0) {
this.scrollByPath.set(sourcePath, scrollY);
}
}
resolveScrollRestore(view: MarkdownView, sourcePath: string): number {
try {
const live = view.previewMode.getScroll();
if (live > 0) {
return live;
}
} catch {
/* ignore */
}
const saved = this.scrollByPath.get(sourcePath);
if (saved !== undefined && saved > 0) {
return saved;
}
try {
return view.editor.getScrollInfo().top;
} catch {
return 0;
}
}
applyScroll(view: MarkdownView, sourcePath: string, scrollY: number): void {
if (scrollY <= 0) {
return;
}
const apply = (): void => {
if (!view.previewMode.containerEl.isConnected) {
return;
}
view.previewMode.applyScroll(scrollY);
this.scrollByPath.set(sourcePath, scrollY);
};
apply();
window.requestAnimationFrame(() => {
apply();
window.requestAnimationFrame(apply);
});
window.setTimeout(apply, 50);
window.setTimeout(apply, 150);
window.setTimeout(apply, 300);
}
/** Strip Plume cache attrs so the next section post-process pass rebuilds blocks. */
static invalidatePreviewDom(view: MarkdownView): void {
for (const el of Array.from(
view.previewMode.containerEl.querySelectorAll<HTMLElement>(
"[data-plume-block-key], .plume-has-block"
)
)) {
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
}
}
import { App, Component, type IconName, MarkdownPostProcessorContext, Notice, requestUrl, setIcon } from "obsidian";
import { renderPlumeMarkdown, type PlumeMarkdownContext } from "./markdown/plume-markdown";
import { applyVuepressMarkdownTransforms } from "./render/markdown-transforms";
import { resolveNodeIcon } from "./icons";
import { registerBlockRenderer } from "./render/block-registry";
import { prepareIconifyIconElement, processIconifyIcons } from "./render/iconify-online";
import { renderCollapseBlock } from "./render/blocks/collapse";
import {
decorateCodeBlockTitles,
scanCodeFenceTitles,
scanCodeFences
} from "./render/code-fence";
import {
type BlockRenderContext,
type PlumeRenderSettings,
toBlockRenderContext,
toPlumeMarkdownContext,
triggerPreviewReflow
} from "./render/context";
import {
BLOCK_PLACEHOLDER_ATTR,
BLOCK_PLACEHOLDER_CLASS,
contentIsOnlyBlocksAndBlankLines,
pruneEmptyMarkdownNodes,
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./render/pipeline";
import { renderTabbedContainer } from "./render/tabbed-container";
import { renderInlineMarkdownInto } from "./render/inline";
import {
fileTreeToCMDText,
normalizeCodeTreePath,
parseAllBlocks,
parseCodeTreeFileNodes,
parseCodeTreeRawContent,
parseFileTreeRawContent,
parseStepsRawContent,
dedentStepBody,
splitFlexSegments,
parseTabsRawContent
} from "./parser";
export type { BlockRenderContext, PlumeRenderSettings } from "./render/context";
export {
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./render/pipeline";
export {
scanCodeFenceTitles,
scanCodeFences,
decorateCodeBlockTitles,
decorateSubtreeCodeFences
} from "./render/code-fence";
import type {
CardContainerAttrs,
CardGridContainerAttrs,
CardMasonryContainerAttrs,
RepoCardContainerAttrs,
LinkCardContainerAttrs,
ImageCardContainerAttrs,
FieldContainerAttrs,
FlexContainerAttrs,
WindowContainerAttrs,
ChatContainerAttrs,
CollapseContainerAttrs,
CodeTabsContainerAttrs,
CodeTreeContainerAttrs,
CodeTreeFileItem,
FileTreeContainerAttrs,
FileTreeIconMode,
FileTreeNode,
ParsedBlock,
PromptContainerAttrs,
PromptContainerType,
TabItem,
TabsContainerAttrs,
TimelineContainerAttrs,
TimelineItemMeta,
TimelineLineStyle,
TimelinePlacement,
AlignContainerAttrs
} from "./types";
interface RenderTreeOptions {
nodes: FileTreeNode[];
attrs: FileTreeContainerAttrs;
defaultIconMode: FileTreeIconMode;
markdownContext?: PlumeMarkdownContext;
}
interface RenderCodeTreeOptions {
files: CodeTreeFileItem[];
attrs: CodeTreeContainerAttrs;
defaultIconMode: FileTreeIconMode;
markdownContext?: RenderTreeOptions["markdownContext"];
}
interface RenderStepsOptions {
content: string;
markdownContext?: RenderTreeOptions["markdownContext"];
defaultIconMode?: FileTreeIconMode;
}
interface RenderPromptContainerOptions {
attrs: PromptContainerAttrs;
content: string;
markdownContext?: RenderTreeOptions["markdownContext"];
}
interface PromptPlaceholderBlock {
attrs: PromptContainerAttrs;
content: string;
}
const ELLIPSIS = "\u2026";
let commentRenderToken = 0;
const PROMPT_HEADER_RE = /^(\s*)(:{3,})\s*(note|info|tip|warning|caution|details|important)\b(.*)$/i;
const PROMPT_PLACEHOLDER_CLASS = "vp-prompt-placeholder";
const PROMPT_PLACEHOLDER_ATTR = "data-vp-prompt-id";
const PROMPT_DEFAULT_TITLES: Record<PromptContainerType, string> = {
note: "NOTE",
info: "INFO",
tip: "TIP",
warning: "WARNING",
caution: "CAUTION",
details: "DETAILS",
important: "IMPORTANT"
};
const PROMPT_TYPE_ICONS: Record<PromptContainerType, string | null> = {
note: "pencil",
info: "info",
tip: "lightbulb",
warning: "alert-triangle",
caution: "alert-octagon",
// details uses the native chevron ::before; skip the icon
details: null,
important: "alert-circle"
};
function applyPromptTitleIcon(host: HTMLElement, type: PromptContainerType): void {
const iconName = PROMPT_TYPE_ICONS[type];
if (!iconName) return;
const span = document.createElement("span");
span.className = "vp-custom-container-icon";
span.setAttribute("aria-hidden", "true");
host.prepend(span);
try {
setIcon(span, iconName);
} catch {
/* setIcon may throw if Lucide name unknown; ignore */
}
}
async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.classList.add("plume-clipboard-fallback");
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function createPlaceholderNode(level: number): FileTreeNode {
return {
filename: ELLIPSIS,
type: "file",
expanded: false,
level,
children: []
};
}
function flattenInlineParagraph(el: HTMLElement): void {
if (el.children.length !== 1) {
return;
}
const only = el.firstElementChild;
if (!(only instanceof HTMLElement) || only.tagName !== "P") {
return;
}
while (only.firstChild) {
el.appendChild(only.firstChild);
}
only.remove();
}
function renderCommentMarkdown(
commentEl: HTMLElement,
rawComment: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
if (!markdownContext) {
commentEl.textContent = rawComment;
return;
}
const markdown = rawComment.split("#").join("\\#");
const token = String(++commentRenderToken);
commentEl.dataset.vpftCommentToken = token;
void renderPlumeMarkdown(commentEl, markdown, markdownContext)
.then(() => {
if (commentEl.dataset.vpftCommentToken !== token || !commentEl.isConnected) {
return;
}
flattenInlineParagraph(commentEl);
})
.catch(() => {
if (commentEl.dataset.vpftCommentToken !== token || !commentEl.isConnected) {
return;
}
commentEl.textContent = rawComment;
});
}
function parsePromptHeaderLine(
line: string
): (PromptContainerAttrs & { markerLen: number; indent: string }) | null {
const match = line.match(PROMPT_HEADER_RE);
if (!match) {
return null;
}
const indent = match[1] ?? "";
const markerLen = match[2]?.length ?? 0;
const type = (match[3] ?? "").toLowerCase() as PromptContainerType;
const title = (match[4] ?? "").trim() || undefined;
return {
type,
title,
markerLen,
indent
};
}
function collectPromptPlaceholderBlocks(markdown: string): {
transformedMarkdown: string;
blocks: Map<string, PromptPlaceholderBlock>;
} {
const lines = markdown.split(/\r?\n/);
const transformedLines: string[] = [];
const blocks = new Map<string, PromptPlaceholderBlock>();
let lineIndex = 0;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
const header = parsePromptHeaderLine(line);
if (!header) {
transformedLines.push(line);
lineIndex += 1;
continue;
}
const bodyLines: string[] = [];
let closeLine = -1;
let nestedContainerDepth = 0;
let fenceChar = "";
let fenceLength = 0;
for (let cursor = lineIndex + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
if (fenceLength > 0) {
bodyLines.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = current.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
bodyLines.push(current);
continue;
}
const closeMatch = current.match(/^\s*(:{3,})\s*$/);
if (closeMatch) {
const markerLen = closeMatch[1]?.length ?? 0;
if (markerLen >= header.markerLen && nestedContainerDepth === 0) {
closeLine = cursor;
break;
}
if (nestedContainerDepth > 0) {
nestedContainerDepth -= 1;
}
bodyLines.push(current);
continue;
}
if (/^\s*:{3,}\s*\S+/.test(current)) {
nestedContainerDepth += 1;
}
bodyLines.push(current);
}
if (closeLine === -1) {
transformedLines.push(line);
lineIndex += 1;
continue;
}
const normalizedBody = bodyLines.map((bodyLine) => {
if (!bodyLine.trim() || !header.indent) {
return bodyLine;
}
return bodyLine.startsWith(header.indent)
? bodyLine.slice(header.indent.length)
: bodyLine;
});
let dedentLength = Number.MAX_SAFE_INTEGER;
for (const bodyLine of normalizedBody) {
if (!bodyLine.trim()) {
continue;
}
const indentMatch = bodyLine.match(/^[\t ]*/);
const lineIndent = indentMatch?.[0].length ?? 0;
dedentLength = Math.min(dedentLength, lineIndent);
}
const finalBody = Number.isFinite(dedentLength) && dedentLength > 0
? normalizedBody.map((bodyLine) => {
if (!bodyLine.trim()) {
return bodyLine;
}
return bodyLine.slice(dedentLength);
})
: normalizedBody;
const id = `vp-prompt-${blocks.size + 1}`;
blocks.set(id, {
attrs: {
type: header.type,
title: header.title
},
content: finalBody.join("\n").replace(/^\n+|\n+$/g, "")
});
transformedLines.push(
`${header.indent}<div class="${PROMPT_PLACEHOLDER_CLASS}" ${PROMPT_PLACEHOLDER_ATTR}="${id}"></div>`
);
lineIndex = closeLine + 1;
}
return {
transformedMarkdown: transformedLines.join("\n"),
blocks
};
}
function renderMarkdownChunk(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
if (!markdown.trim()) {
return;
}
if (!markdownContext) {
container.textContent = markdown;
return;
}
void renderPlumeMarkdown(container, markdown, markdownContext);
}
function renderMarkdownWithPromptContainers(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
const source = markdown.trim();
if (!source) {
return;
}
const transformed = collectPromptPlaceholderBlocks(source);
if (transformed.blocks.size === 0) {
renderMarkdownChunk(container, markdown, markdownContext);
return;
}
if (!markdownContext) {
container.textContent = source;
return;
}
void renderPlumeMarkdown(container, transformed.transformedMarkdown, markdownContext)
.then(() => {
if (!container.isConnected) {
return;
}
const placeholders = Array.from(
container.querySelectorAll(`.${PROMPT_PLACEHOLDER_CLASS}[${PROMPT_PLACEHOLDER_ATTR}]`)
).filter((node): node is HTMLElement => {
return node instanceof HTMLElement;
});
for (const placeholder of placeholders) {
const id = placeholder.getAttribute(PROMPT_PLACEHOLDER_ATTR);
if (!id) {
continue;
}
const block = transformed.blocks.get(id);
if (!block) {
continue;
}
placeholder.removeAttribute(PROMPT_PLACEHOLDER_ATTR);
placeholder.classList.remove(PROMPT_PLACEHOLDER_CLASS);
placeholder.empty();
renderPromptContainerInto(placeholder, {
attrs: block.attrs,
content: block.content,
markdownContext
});
}
})
.catch(() => {
if (!container.isConnected) {
return;
}
container.empty();
container.textContent = source;
});
}
export function renderMarkdownWithPromptContainersInto(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
renderMarkdownWithPromptContainers(container, markdown, markdownContext);
}
export function renderFileTreeInto(container: HTMLElement, options: RenderTreeOptions): void {
const mode = options.attrs.icon ?? options.defaultIconMode;
const wrapper = document.createElement("div");
wrapper.className = "vp-file-tree obsidian-vuepress-file-tree";
container.appendChild(wrapper);
if (options.attrs.title) {
const title = document.createElement("p");
title.className = "vp-file-tree-title";
title.textContent = options.attrs.title;
wrapper.appendChild(title);
}
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "obsidian-file-tree-copy clickable-icon";
copyButton.setAttribute("aria-label", "Copy file tree");
setIcon(copyButton, "copy");
wrapper.appendChild(copyButton);
const cmdText = fileTreeToCMDText(options.nodes).trim();
copyButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(cmdText);
copyButton.classList.add("is-copied");
setIcon(copyButton, "check");
window.setTimeout(() => {
copyButton.classList.remove("is-copied");
setIcon(copyButton, "copy");
}, 1200);
} catch {
new Notice("Failed to copy file tree text.");
}
});
let activeInfoElement: HTMLElement | null = null;
const renderNodes = (parent: HTMLElement, nodes: FileTreeNode[], parentPath: string): void => {
for (const node of nodes) {
const nodeElement = document.createElement("div");
nodeElement.className = "vp-file-tree-node";
parent.appendChild(nodeElement);
const nodeChildren =
node.type === "folder" && node.children.length === 0
? [createPlaceholderNode(node.level + 1)]
: node.children;
const nodeType: "folder" | "file" = nodeChildren.length > 0 ? "folder" : node.type;
const isPlaceholder = node.filename === ELLIPSIS || node.filename === "...";
const info = document.createElement("p");
info.classList.add("vp-file-tree-info", nodeType);
info.style.setProperty("--file-tree-level", String(-node.level));
nodeElement.appendChild(info);
if (node.focus) {
info.classList.add("focus");
}
if (node.diff) {
info.classList.add("diff", node.diff);
}
const icon = isPlaceholder ? null : document.createElement("span");
if (icon) {
icon.className = "ft-icon";
info.appendChild(icon);
}
let expanded = nodeType === "folder" ? node.expanded !== false : false;
const group = nodeType === "folder" ? document.createElement("div") : null;
if (group) {
group.className = "group";
nodeElement.appendChild(group);
}
const applyIcon = (): void => {
if (!icon) {
return;
}
const iconDescriptor = resolveNodeIcon(node.filename, nodeType, expanded, mode);
icon.className = "ft-icon";
if (iconDescriptor.colorClass) {
icon.classList.add(iconDescriptor.colorClass);
}
if (iconDescriptor.iconifyId) {
prepareIconifyIconElement(icon, iconDescriptor.iconifyId);
void processIconifyIcons(icon);
return;
}
icon.classList.remove("ft-icon-online");
icon.empty();
setIcon(icon, iconDescriptor.icon);
};
const applyFolderState = (): void => {
if (nodeType !== "folder" || !group) {
return;
}
if (expanded) {
info.classList.add("expanded");
group.hidden = false;
} else {
info.classList.remove("expanded");
group.hidden = true;
}
applyIcon();
};
applyIcon();
const name = document.createElement("span");
name.classList.add("name", nodeType);
name.textContent = node.filename;
info.appendChild(name);
if (node.comment) {
const comment = document.createElement("span");
comment.className = "comment";
renderCommentMarkdown(comment, node.comment, options.markdownContext);
info.appendChild(comment);
}
const nodePath = parentPath ? `${parentPath}/${node.filename}` : node.filename;
info.dataset.path = nodePath;
if (group) {
applyFolderState();
renderNodes(group, nodeChildren, nodePath);
}
info.addEventListener("click", (event: MouseEvent) => {
if (isPlaceholder) {
return;
}
const target = event.target as HTMLElement;
if (nodeType === "folder") {
if (target.closest(".comment")) {
return;
}
expanded = !expanded;
applyFolderState();
if (options.markdownContext?.app) {
triggerPreviewReflow(options.markdownContext.app);
}
return;
}
if (activeInfoElement && activeInfoElement !== info) {
activeInfoElement.classList.remove("active");
}
info.classList.add("active");
activeInfoElement = info;
});
}
};
renderNodes(wrapper, options.nodes, "");
}
function normalizeHeightValue(height: string | undefined): string | undefined {
if (!height) {
return undefined;
}
const value = height.trim();
if (!value) {
return undefined;
}
if (/^\d+(?:\.\d+)?$/.test(value)) {
return `${value}px`;
}
return value;
}
function renderPlainCodeBlock(container: HTMLElement, language: string, content: string): void {
const pre = document.createElement("pre");
pre.className = "vp-code-tree-pre";
const code = document.createElement("code");
code.className = `language-${language || "text"}`;
code.textContent = content;
pre.appendChild(code);
container.appendChild(pre);
}
export function renderCodeTreeInto(container: HTMLElement, options: RenderCodeTreeOptions): void {
const normalizedFiles: CodeTreeFileItem[] = [];
const fileMap = new Map<string, CodeTreeFileItem>();
for (const file of options.files) {
const filepath = normalizeCodeTreePath(file.filepath);
if (!filepath) {
continue;
}
const existing = fileMap.get(filepath);
if (existing) {
if (file.active) {
existing.active = true;
}
continue;
}
const normalizedFile: CodeTreeFileItem = {
...file,
filepath,
language: file.language || "text"
};
fileMap.set(filepath, normalizedFile);
normalizedFiles.push(normalizedFile);
}
if (normalizedFiles.length === 0) {
return;
}
const mode = options.attrs.icon ?? options.defaultIconMode;
const wrapper = document.createElement("div");
wrapper.className = "vp-code-tree obsidian-vuepress-file-tree obsidian-vuepress-code-tree";
container.appendChild(wrapper);
if (options.attrs.title) {
const title = document.createElement("p");
title.className = "vp-code-tree-title";
title.textContent = options.attrs.title;
wrapper.appendChild(title);
}
const normalizedHeight = normalizeHeightValue(options.attrs.height);
if (normalizedHeight) {
wrapper.style.setProperty("--vp-code-tree-height", normalizedHeight);
}
const body = document.createElement("div");
body.className = "vp-code-tree-body";
wrapper.appendChild(body);
const nav = document.createElement("div");
nav.className = "vp-code-tree-nav";
body.appendChild(nav);
const panel = document.createElement("div");
panel.className = "vp-code-tree-panel";
body.appendChild(panel);
const panelHeader = document.createElement("div");
panelHeader.className = "vp-code-tree-panel-header";
panel.appendChild(panelHeader);
const panelEntry = document.createElement("span");
panelEntry.className = "vp-code-tree-panel-entry";
panelHeader.appendChild(panelEntry);
const panelContent = document.createElement("div");
panelContent.className = "vp-code-tree-panel-content";
panel.appendChild(panelContent);
const explicitEntry = options.attrs.entry ? normalizeCodeTreePath(options.attrs.entry) : "";
const initialPath =
(explicitEntry && fileMap.has(explicitEntry) ? explicitEntry : undefined)
?? normalizedFiles.find((file) => file.active)?.filepath
?? normalizedFiles[0].filepath;
const treeNodes = parseCodeTreeFileNodes(normalizedFiles);
let activePath = initialPath;
let activeInfoElement: HTMLElement | null = null;
let panelRenderToken = 0;
const fileInfoMap = new Map<string, HTMLElement>();
const setActiveInfo = (filepath: string): void => {
const next = fileInfoMap.get(filepath);
if (!(next instanceof HTMLElement)) {
return;
}
if (activeInfoElement && activeInfoElement !== next) {
activeInfoElement.classList.remove("active");
}
next.classList.add("active");
activeInfoElement = next;
};
const renderPanel = (filepath: string): void => {
const file = fileMap.get(filepath);
if (!file) {
return;
}
panelEntry.textContent = file.filepath;
panelContent.empty();
if (!options.markdownContext) {
renderPlainCodeBlock(panelContent, file.language, file.content);
return;
}
const token = String(++panelRenderToken);
panelContent.dataset.vpctRenderToken = token;
const markdown = `\`\`\`${file.language}\n${file.content}\n\`\`\``;
void renderPlumeMarkdown(panelContent, markdown, options.markdownContext)
.catch(() => {
if (panelContent.dataset.vpctRenderToken !== token || !panelContent.isConnected) {
return;
}
panelContent.empty();
renderPlainCodeBlock(panelContent, file.language, file.content);
});
};
const renderNodes = (parent: HTMLElement, nodes: FileTreeNode[], parentPath: string): void => {
for (const node of nodes) {
const nodeElement = document.createElement("div");
nodeElement.className = "vp-file-tree-node";
parent.appendChild(nodeElement);
const hasChildren = node.children.length > 0;
const nodeType: "folder" | "file" = hasChildren || node.type === "folder" ? "folder" : "file";
const info = document.createElement("p");
info.classList.add("vp-file-tree-info", nodeType);
info.style.setProperty("--file-tree-level", String(-node.level));
nodeElement.appendChild(info);
const icon = document.createElement("span");
icon.className = "ft-icon";
info.appendChild(icon);
let expanded = nodeType === "folder" ? node.expanded !== false : false;
const group = nodeType === "folder" ? document.createElement("div") : null;
if (group) {
group.className = "group";
nodeElement.appendChild(group);
}
const applyIcon = (): void => {
const iconDescriptor = resolveNodeIcon(node.filename, nodeType, expanded, mode);
icon.className = "ft-icon";
if (iconDescriptor.colorClass) {
icon.classList.add(iconDescriptor.colorClass);
}
if (iconDescriptor.iconifyId) {
prepareIconifyIconElement(icon, iconDescriptor.iconifyId);
void processIconifyIcons(icon);
return;
}
icon.classList.remove("ft-icon-online");
icon.empty();
setIcon(icon, iconDescriptor.icon);
};
const applyFolderState = (): void => {
if (nodeType !== "folder" || !group) {
return;
}
if (expanded) {
info.classList.add("expanded");
group.hidden = false;
} else {
info.classList.remove("expanded");
group.hidden = true;
}
applyIcon();
};
applyIcon();
const name = document.createElement("span");
name.classList.add("name", nodeType);
name.textContent = node.filename;
info.appendChild(name);
const currentPath = parentPath ? `${parentPath}/${node.filename}` : node.filename;
const filepath = normalizeCodeTreePath(node.filepath ?? currentPath);
info.dataset.path = filepath;
if (nodeType === "file") {
fileInfoMap.set(filepath, info);
}
if (group) {
applyFolderState();
renderNodes(group, node.children, currentPath);
}
info.addEventListener("click", () => {
if (nodeType === "folder") {
expanded = !expanded;
applyFolderState();
return;
}
if (!fileMap.has(filepath)) {
return;
}
activePath = filepath;
setActiveInfo(activePath);
renderPanel(activePath);
});
}
};
renderNodes(nav, treeNodes, "");
setActiveInfo(activePath);
renderPanel(activePath);
}
export function renderPromptContainerInto(container: HTMLElement, options: RenderPromptContainerOptions): void {
const type = options.attrs.type;
const title = options.attrs.title?.trim() || PROMPT_DEFAULT_TITLES[type];
const content = options.content.trim();
if (type === "details") {
const details = document.createElement("details");
details.className = "vp-custom-container obsidian-vuepress-prompt-container details";
container.appendChild(details);
const summary = document.createElement("summary");
summary.className = "vp-custom-container-title";
summary.textContent = title;
applyPromptTitleIcon(summary, type);
details.appendChild(summary);
const body = document.createElement("div");
body.className = "vp-custom-container-content";
details.appendChild(body);
renderMarkdownWithPromptContainers(body, content, options.markdownContext);
return;
}
const wrapper = document.createElement("div");
wrapper.className = `vp-custom-container obsidian-vuepress-prompt-container ${type}`;
container.appendChild(wrapper);
const titleElement = document.createElement("p");
titleElement.className = "vp-custom-container-title";
titleElement.textContent = title;
applyPromptTitleIcon(titleElement, type);
wrapper.appendChild(titleElement);
const body = document.createElement("div");
body.className = "vp-custom-container-content";
wrapper.appendChild(body);
renderMarkdownWithPromptContainers(body, content, options.markdownContext);
}
export function renderStepsInto(container: HTMLElement, options: RenderStepsOptions): void {
if (!options.markdownContext) {
void renderStepsContent(container, options.content);
return;
}
void renderStepsContent(
container,
options.content,
toBlockRenderContext(options.markdownContext, options.defaultIconMode ?? "colored")
);
}
// ===========================================================================
// Unified block rendering pipeline
// ===========================================================================
/** Collect masonry cells via Plume block renderers, or markdown for bare code fences. */
export async function gatherMasonryItems(
content: string,
ctx: BlockRenderContext
): Promise<HTMLElement[]> {
const trimmed = content.replace(/^\n+|\n+$/g, "");
if (!trimmed) {
return [];
}
const blocks = parseAllBlocks(trimmed, ctx.defaultIconMode);
if (blocks.length > 0 && contentIsOnlyBlocksAndBlankLines(trimmed, blocks)) {
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
if (document.body) {
document.body.appendChild(staging);
}
try {
await renderPlumeBlocksInto(staging, blocks, ctx);
const plumeItems = collectMasonryItems(staging);
if (plumeItems.length > 0) {
return plumeItems;
}
} finally {
staging.remove();
}
}
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
if (document.body) {
document.body.appendChild(staging);
}
try {
await renderInnerMarkdown(staging, trimmed, ctx);
return collectMasonryItems(staging);
} finally {
staging.remove();
}
}
async function buildMasonryItems(
content: string,
wrapper: HTMLElement,
ctx: BlockRenderContext
): Promise<HTMLElement[]> {
const items = await gatherMasonryItems(content, ctx);
if (items.length === 0) {
return [];
}
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
wrapper.appendChild(staging);
for (const item of items) {
staging.appendChild(item);
}
const collected = collectMasonryItems(staging);
staging.remove();
return collected;
}
interface NormalizedTabsAttrs extends TabsContainerAttrs {}
function normalizeTabs(rawTabs: TabItem[]): TabItem[] {
const seen = new Map<string, number>();
const out: TabItem[] = [];
for (let i = 0; i < rawTabs.length; i += 1) {
const t = rawTabs[i];
const title = t.title || `Tab ${i + 1}`;
const baseValue = t.value || title;
const dup = seen.get(baseValue) ?? 0;
seen.set(baseValue, dup + 1);
out.push({
...t,
title,
value: dup === 0 ? baseValue : `${baseValue}-${dup + 1}`
});
}
return out;
}
/**
* Dispatch a single parsed block to the appropriate renderer.
* Inner markdown content is rendered recursively via `renderInnerMarkdown`,
* so nested containers Just Work.
*/
export async function renderBlock(
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
): Promise<void> {
switch (block.type) {
case "file-tree": {
const nodes = parseFileTreeRawContent(block.rawContent);
if (nodes.length === 0) return;
renderFileTreeInto(container, {
nodes,
attrs: block.attrs as FileTreeContainerAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "code-tree": {
const files = parseCodeTreeRawContent(block.rawContent);
if (files.length === 0) return;
renderCodeTreeInto(container, {
files,
attrs: block.attrs as CodeTreeContainerAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "code-tree-embed": {
const attrs = block.attrs as CodeTreeContainerAttrs & { dirPath: string };
if (!ctx.resolveCodeTreeEmbed) return;
const files = await ctx.resolveCodeTreeEmbed(ctx.sourcePath, attrs.dirPath);
if (!files || files.length === 0) return;
const finalAttrs: CodeTreeContainerAttrs = { ...attrs };
delete (finalAttrs as Record<string, unknown>).dirPath;
if (!finalAttrs.entry) {
finalAttrs.entry = files[0].filepath;
}
renderCodeTreeInto(container, {
files,
attrs: finalAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "tabs": {
const tabs = normalizeTabs(parseTabsRawContent(block.rawContent));
if (tabs.length === 0) return;
await renderTabsBlock(container, tabs, block.attrs as NormalizedTabsAttrs, ctx);
return;
}
case "code-tabs": {
const tabs = normalizeTabs(parseTabsRawContent(block.rawContent));
if (tabs.length === 0) return;
await renderCodeTabsBlock(container, tabs, block.attrs as CodeTabsContainerAttrs, ctx);
return;
}
case "steps": {
await renderStepsBlock(container, block.rawContent, ctx);
return;
}
case "prompt": {
await renderPromptBlock(container, block.rawContent, block.attrs as PromptContainerAttrs, ctx);
return;
}
case "collapse": {
await renderCollapseBlock(container, block.rawContent, block.attrs as CollapseContainerAttrs, ctx);
return;
}
case "card": {
await renderCardBlock(container, block.rawContent, block.attrs as CardContainerAttrs, ctx);
return;
}
case "card-grid": {
await renderCardGridBlock(container, block.rawContent, block.attrs as CardGridContainerAttrs, ct
… (demo build 截断)const BADGE_TYPES = new Set(["tip", "info", "warning", "danger", "note", "important"]);
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
function buildBadgeHtml(attrs: string, body = ""): string {
const type = parseAttrValue(attrs, "type") ?? "tip";
const text = parseAttrValue(attrs, "text") ?? body.trim() ?? "";
const color = parseAttrValue(attrs, "color");
const bgColor =
parseAttrValue(attrs, "bg-color")
?? parseAttrValue(attrs, "bgColor")
?? parseAttrValue(attrs, "bgcolor");
const borderColor =
parseAttrValue(attrs, "border-color")
?? parseAttrValue(attrs, "borderColor")
?? parseAttrValue(attrs, "bordercolor");
const normalized = type.toLowerCase();
const cls = normalized.replace(/[^a-z0-9_-]/g, "") || "tip";
const classes = ["vp-badge", cls];
if (!BADGE_TYPES.has(cls) && !color && !bgColor && !borderColor) {
classes.push("tip");
}
const style: string[] = [];
if (color) style.push(`color:${escapeHtml(color)}`);
if (bgColor) style.push(`background-color:${escapeHtml(bgColor)}`);
if (borderColor) style.push(`border-color:${escapeHtml(borderColor)}`);
const styleAttr = style.length > 0 ? ` style="${style.join(";")}"` : "";
return `<span class="${classes.join(" ")}"${styleAttr}>${escapeHtml(text)}</span>`;
}
function replaceBadgeTagsInLine(line: string): string {
return line
.replace(/<Badge\b([^>]*)\/>/gi, (_matched, attrs: string) => {
return buildBadgeHtml(attrs);
})
.replace(/<Badge\b([^>]*)>(.*?)<\/Badge>/gi, (_matched, attrs: string, body: string) => {
return buildBadgeHtml(attrs, body);
});
}
export function replaceBadgeTagsInMarkdown(markdown: string): string {
if (!/<Badge\b/i.test(markdown)) {
return markdown;
}
const lines = markdown.split(/\r?\n/);
const out: string[] = [];
let fenceChar = "";
let fenceLength = 0;
for (const line of lines) {
if (fenceLength > 0) {
out.push(line);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(line)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = line.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
out.push(line);
continue;
}
out.push(replaceBadgeTagsInLine(line));
}
return out.join("\n");
}
import type { BlockRenderContext } from "./context";
import type { ParsedBlock } from "../types";
export type BlockRenderer = (
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
) => Promise<void>;
let renderBlockImpl: BlockRenderer | null = null;
export function registerBlockRenderer(impl: BlockRenderer): void {
renderBlockImpl = impl;
}
export async function invokeBlockRenderer(
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
): Promise<void> {
if (!renderBlockImpl) {
throw new Error("[theme-plume] block renderer not registered");
}
await renderBlockImpl(container, block, ctx);
}
import { setIcon } from "obsidian";
import { parseCollapseRawContent } from "../../parser";
import type { CollapseContainerAttrs } from "../../types";
import { hashString } from "../../utils/hash";
import type { BlockRenderContext } from "../context";
import { renderInlineMarkdownInto } from "../inline";
import { renderNestedMarkdownContent } from "../pipeline";
interface CollapseItemMeta {
expand?: boolean;
}
function shouldEagerRenderCollapseBody(
details: HTMLDetailsElement,
index: number,
attrs: CollapseContainerAttrs,
activeIndex: number,
ctx: BlockRenderContext
): boolean {
if (!ctx.settings?.collapseLazyBodies) {
return true;
}
if (!details.open) {
return false;
}
if (attrs.accordion) {
return index === activeIndex;
}
return true;
}
function createCollapseDetailsElement(
index: number,
attrs: CollapseContainerAttrs,
itemMeta: CollapseItemMeta[],
activeIndex: number
): HTMLDetailsElement {
const details = document.createElement("details");
details.className = "vp-collapse-item";
details.setAttribute("role", "group");
const meta = itemMeta[index];
const expanded = attrs.accordion
? index === activeIndex
: (meta?.expand ?? attrs.expand ?? false);
details.open = expanded;
details.dataset.index = String(index);
const summary = document.createElement("summary");
summary.className = "vp-collapse-header";
summary.setAttribute("role", "button");
summary.setAttribute("tabindex", "0");
summary.setAttribute("aria-expanded", expanded ? "true" : "false");
summary.id = `vp-collapse-summary-${index}`;
details.appendChild(summary);
const chevron = document.createElement("span");
chevron.className = "vp-collapse-chevron";
chevron.setAttribute("aria-hidden", "true");
setIcon(chevron, "chevron-right");
summary.appendChild(chevron);
const title = document.createElement("span");
title.className = "vp-collapse-title";
summary.appendChild(title);
const content = document.createElement("div");
content.className = "vp-collapse-content";
content.setAttribute("role", "region");
content.setAttribute("aria-labelledby", summary.id);
content.setAttribute("tabindex", "0");
details.appendChild(content);
const inner = document.createElement("div");
inner.className = "vp-collapse-content-inner";
content.appendChild(inner);
return details;
}
function bindCollapseAccordion(wrapper: HTMLElement, details: HTMLDetailsElement): void {
details.addEventListener("toggle", () => {
if (!details.open) {
return;
}
for (const sibling of Array.from(
wrapper.querySelectorAll<HTMLDetailsElement>(":scope > .vp-collapse-item")
)) {
if (sibling !== details) {
sibling.open = false;
}
}
});
}
function scheduleCollapseBody(
details: HTMLDetailsElement,
inner: HTMLElement,
body: string,
ctx: BlockRenderContext,
eager: boolean
): void {
const bodyRevision = hashString(body);
const mountBody = async (): Promise<void> => {
if (
inner.dataset.plumeCollapseBodyRev === bodyRevision
&& inner.childElementCount > 0
) {
return;
}
inner.dataset.plumeCollapseBodyRev = bodyRevision;
inner.dataset.plumeCollapseBody = "1";
inner.empty();
await renderNestedMarkdownContent(inner, body, ctx);
};
if (eager) {
void mountBody();
return;
}
details.addEventListener("toggle", () => {
if (details.open) {
void mountBody();
}
});
}
export async function renderCollapseBlock(
container: HTMLElement,
rawContent: string,
attrs: CollapseContainerAttrs,
ctx: BlockRenderContext
): Promise<void> {
const content = rawContent.replace(/^\n+|\n+$/g, "");
if (!content) {
return;
}
const { preamble, items } = parseCollapseRawContent(content);
if (items.length === 0 && !preamble.trim()) {
return;
}
const wrapper = document.createElement("div");
wrapper.className = "vp-collapse obsidian-vuepress-collapse";
if (attrs.accordion) {
wrapper.dataset.accordion = "true";
}
container.appendChild(wrapper);
if (preamble.trim()) {
const intro = document.createElement("div");
intro.className = "vp-collapse-preamble";
wrapper.appendChild(intro);
await renderNestedMarkdownContent(intro, preamble, ctx);
}
let activeIndex = -1;
if (attrs.accordion) {
activeIndex = items.findIndex((item) => item.expand === true);
if (activeIndex === -1 && attrs.expand) {
activeIndex = 0;
}
}
for (const [index, item] of items.entries()) {
const details = createCollapseDetailsElement(
index,
attrs,
items.map((i) => ({ expand: i.expand })),
activeIndex
);
wrapper.appendChild(details);
const title = details.querySelector(".vp-collapse-title");
const inner = details.querySelector(".vp-collapse-content-inner");
const titleText = item.titleLines.join(" ").trim();
if (title instanceof HTMLElement && titleText) {
if (/[*_[\]`]/.test(titleText)) {
await renderInlineMarkdownInto(title, titleText, ctx, { phrasingOnly: true });
} else {
title.textContent = titleText;
}
}
if (inner instanceof HTMLElement && item.body.trim()) {
const eager = shouldEagerRenderCollapseBody(details, index, attrs, activeIndex, ctx);
scheduleCollapseBody(details, inner, item.body, ctx, eager);
}
if (attrs.accordion) {
bindCollapseAccordion(wrapper, details);
}
}
}
import { setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import type { FileTreeIconMode } from "../types";
import { prepareIconifyIconElement, processIconifyIcons } from "./iconify-online";
export const CODE_TITLE_PROCESSED_ATTR = "data-vp-code-title-done";
export function scanCodeFenceTitles(markdown: string): Array<{ title?: string }> {
return scanCodeFences(markdown).map((f) => ({ title: f.title }));
}
export function scanCodeFences(
markdown: string
): Array<{ title?: string; openLine: number; closeLine: number }> {
const lines = markdown.split(/\r?\n/);
const result: Array<{ title?: string; openLine: number; closeLine: number }> = [];
let fenceChar = "";
let fenceLen = 0;
let openLine = -1;
let currentTitle: string | undefined;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (fenceLen > 0) {
const closeRe = new RegExp(`^\\s*${fenceChar}{${fenceLen},}\\s*$`);
if (closeRe.test(line)) {
result.push({ title: currentTitle, openLine, closeLine: i });
fenceChar = "";
fenceLen = 0;
openLine = -1;
currentTitle = undefined;
}
continue;
}
const open = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (!open) continue;
fenceChar = open[2][0];
fenceLen = open[2].length;
openLine = i;
const info = open[3] ?? "";
const tm = info.match(/\btitle\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+))/);
currentTitle = tm ? (tm[1] ?? tm[2] ?? tm[3]) : undefined;
}
if (fenceLen > 0 && openLine >= 0) {
result.push({ title: currentTitle, openLine, closeLine: lines.length - 1 });
}
return result;
}
function listCodeBlockPres(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll("pre")).filter((pre) => {
const first = pre.firstElementChild;
return first != null && first.tagName === "CODE";
});
}
function resolveCodeBlockIconFilename(title: string, pre: HTMLElement | null): string {
const trimmed = title.trim();
if (trimmed.includes(".")) {
return trimmed;
}
const code = pre?.querySelector("code");
const langMatch = code?.className.match(/\blanguage-([\w+#-]+)\b/i);
const lang = langMatch?.[1]?.replace(/[#+].*$/, "");
if (lang && lang !== "plaintext" && lang !== "text") {
return `${trimmed || "file"}.${lang}`;
}
return trimmed || "file.txt";
}
function applyCodeTitleIcon(
host: HTMLElement,
title: string,
mode: FileTreeIconMode,
pre?: HTMLElement | null
): void {
const fileName = resolveCodeBlockIconFilename(title, pre ?? null);
const desc = resolveNodeIcon(fileName, "file", false, mode);
host.className = "vp-code-block-title-icon ft-icon";
if (desc.colorClass) {
host.classList.add(desc.colorClass);
}
host.empty();
if (desc.iconifyId) {
prepareIconifyIconElement(host, desc.iconifyId);
void processIconifyIcons(host);
return;
}
try {
setIcon(host, desc.icon);
} catch {
/* Lucide id may be missing */
}
}
export function decorateCodeBlockTitles(
container: HTMLElement,
fences: Array<{ title?: string }>,
mode: FileTreeIconMode
): void {
const pres = listCodeBlockPres(container);
let preIndex = 0;
for (let fi = 0; fi < fences.length; fi += 1) {
const newTitle = fences[fi].title;
if (preIndex >= pres.length) {
break;
}
const pre = pres[preIndex];
preIndex += 1;
const existing = pre.parentElement?.classList.contains("vp-code-block-title")
? (pre.parentElement as HTMLElement)
: null;
if (existing) {
if (!newTitle) {
existing.replaceWith(pre);
pre.removeAttribute(CODE_TITLE_PROCESSED_ATTR);
continue;
}
if (existing.dataset.title !== newTitle) {
updateWrapperTitle(existing, newTitle, mode);
}
continue;
}
if (!newTitle) {
pre.removeAttribute(CODE_TITLE_PROCESSED_ATTR);
continue;
}
pre.setAttribute(CODE_TITLE_PROCESSED_ATTR, "1");
wrapPreWithTitle(pre, newTitle, mode);
}
for (const wrapper of Array.from(
container.querySelectorAll<HTMLElement>(".vp-code-block-title")
)) {
const title = wrapper.dataset.title;
if (!title) continue;
const pre = wrapper.querySelector("pre");
const label = wrapper.querySelector(".vp-code-block-title-text");
if (!label) continue;
const iconHost = label.querySelector(".vp-code-block-title-icon");
if (!(iconHost instanceof HTMLElement)) continue;
const hasSvg =
iconHost.classList.contains("ft-icon-online") && iconHost.querySelector("svg");
const hasLucide = iconHost.querySelector("svg");
if (hasSvg || hasLucide) continue;
applyCodeTitleIcon(iconHost, title, mode, pre);
}
}
/** Decorate any fenced code with titles inside a rendered subtree (e.g. after nested blocks). */
export function decorateSubtreeCodeFences(
root: HTMLElement,
markdown: string,
mode: FileTreeIconMode
): void {
if (!markdown.trim()) {
return;
}
decorateCodeBlockTitles(root, scanCodeFenceTitles(markdown), mode);
}
function updateWrapperTitle(wrapper: HTMLElement, title: string, mode: FileTreeIconMode): void {
wrapper.dataset.title = title;
const label = wrapper.querySelector(".vp-code-block-title-text");
if (!label) return;
const pre = wrapper.querySelector("pre");
while (label.firstChild) label.removeChild(label.firstChild);
const iconHost = document.createElement("span");
applyCodeTitleIcon(iconHost, title, mode, pre);
label.appendChild(iconHost);
label.appendChild(document.createTextNode(title));
}
function wrapPreWithTitle(pre: HTMLElement, title: string, mode: FileTreeIconMode): void {
const parent = pre.parentElement;
if (!parent) return;
const wrapper = document.createElement("div");
wrapper.className = "vp-code-block-title";
wrapper.dataset.title = title;
const bar = document.createElement("div");
bar.className = "vp-code-block-title-bar";
const label = document.createElement("span");
label.className = "vp-code-block-title-text";
const iconHost = document.createElement("span");
applyCodeTitleIcon(iconHost, title, mode, pre);
label.appendChild(iconHost);
label.appendChild(document.createTextNode(title));
bar.appendChild(label);
parent.insertBefore(wrapper, pre);
wrapper.appendChild(bar);
wrapper.appendChild(pre);
}
import type { App, Component, MarkdownPostProcessorContext } from "obsidian";
import type { PlumeMarkdownContext } from "../markdown/plume-markdown";
import type { CodeTreeFileItem, FileTreeIconMode } from "../types";
export interface BlockRenderContext {
app: App;
sourcePath: string;
component: Component;
postProcessorCtx?: MarkdownPostProcessorContext;
defaultIconMode: FileTreeIconMode;
renderMarkdown?: (container: HTMLElement, markdown: string) => Promise<void>;
/** Resolve a @[code-tree](path) embed into a flat list of CodeTreeFileItem. */
resolveCodeTreeEmbed?: (
sourcePath: string,
dirPath: string
) => Promise<CodeTreeFileItem[] | null>;
/** Plugin settings snapshot for renderers. */
settings?: PlumeRenderSettings;
/** Bumps on each editor change so nested UI (tabs/collapse) can invalidate caches. */
contentEpoch?: number;
}
/** Settings passed into the render pipeline (subset of plugin settings). */
export interface PlumeRenderSettings {
defaultIconMode: FileTreeIconMode;
persistTabSelection: boolean;
collapseLazyBodies: boolean;
tabsLazyPanels: boolean;
debugRender: boolean;
}
export function toPlumeMarkdownContext(ctx: BlockRenderContext): PlumeMarkdownContext {
return {
app: ctx.app,
sourcePath: ctx.sourcePath,
component: ctx.component,
postProcessorCtx: ctx.postProcessorCtx
};
}
export function toBlockRenderContext(
md: PlumeMarkdownContext,
defaultIconMode: FileTreeIconMode,
settings?: PlumeRenderSettings
): BlockRenderContext {
return {
app: md.app,
sourcePath: md.sourcePath,
component: md.component,
postProcessorCtx: md.postProcessorCtx,
defaultIconMode,
settings
};
}
export function triggerPreviewReflow(app: App): void {
window.requestAnimationFrame(() => {
try {
app.workspace.trigger("resize");
} catch {
/* best-effort */
}
});
}
import { describe, expect, it } from "vitest";
import { replaceIconSyntaxInMarkdown } from "./icon-transform";
describe("icon transform", () => {
it("renders VuePress iconify inline syntax", () => {
const html = replaceIconSyntaxInMarkdown("before ::mdi:home:: after");
expect(html).toContain("vp-icon");
expect(html).toContain('data-vp-icon="mdi:home"');
expect(html).toContain("ft-icon-online");
});
it("renders size and color options", () => {
const html = replaceIconSyntaxInMarkdown("::mdi:home =24px /#f00::");
expect(html).toContain("font-size:24px");
expect(html).toContain("color:#f00");
});
it("renders Icon and VPIcon component tags", () => {
const html = replaceIconSyntaxInMarkdown(
'<Icon name="mdi:home" /> <VPIcon provider="iconify" name="mdi:account"></VPIcon>'
);
expect(html).toContain('data-vp-icon="mdi:home"');
expect(html).toContain('data-vp-icon="mdi:account"');
});
it("does not replace inside fenced code blocks", () => {
const md = "```md\n::mdi:home::\n```";
expect(replaceIconSyntaxInMarkdown(md)).toBe(md);
});
});
import { createIconifySpanHtml } from "./iconify-online";
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
interface ResolvedInlineIcon {
provider: string;
name: string;
size?: string;
color?: string;
}
function resolveInlineIcon(content: string): ResolvedInlineIcon | null {
let provider = "iconify";
let size: string | undefined;
let color: string | undefined;
const parts = content.trim().split(/\s+/).filter(Boolean);
if (/^(iconify|iconfont|fontawesome)$/i.test(parts[0] ?? "")) {
provider = (parts.shift() ?? provider).toLowerCase();
}
const nameParts: string[] = [];
for (const part of parts) {
if (part.startsWith("=")) {
size = part.slice(1);
} else if (part.startsWith("/")) {
color = part.slice(1);
} else {
nameParts.push(part);
}
}
const cleaned = nameParts.join(" ").trim();
if (provider !== "iconify") {
return null;
}
const name = cleaned.split(/\s+/)[0] ?? "";
if (!name || !name.includes(":")) {
return null;
}
return { provider, name, size, color };
}
function buildIconHtml(icon: ResolvedInlineIcon): string | null {
const style: string[] = [];
if (icon.size) {
const size = escapeHtml(icon.size);
style.push(`font-size:${size}`, `width:${size}`, `height:${size}`);
}
if (icon.color) {
style.push(`color:${escapeHtml(icon.color)}`);
}
return createIconifySpanHtml(icon.name, "vp-icon", style.join(";"));
}
function replaceIconTagsInLine(line: string): string {
return line
.replace(/<(?:Icon|VPIcon)\b([^>]*)\/>/gi, (matched, attrs: string) => {
const provider = parseAttrValue(attrs, "provider") ?? "iconify";
if (provider !== "iconify") {
return matched;
}
const name = parseAttrValue(attrs, "name");
if (!name) {
return matched;
}
const html = buildIconHtml({
provider,
name,
size: parseAttrValue(attrs, "size"),
color: parseAttrValue(attrs, "color")
});
return html ?? matched;
})
.replace(/<(?:Icon|VPIcon)\b([^>]*)>\s*<\/(?:Icon|VPIcon)>/gi, (matched, attrs: string) => {
const provider = parseAttrValue(attrs, "provider") ?? "iconify";
if (provider !== "iconify") {
return matched;
}
const name = parseAttrValue(attrs, "name");
if (!name) {
return matched;
}
const html = buildIconHtml({
provider,
name,
size: parseAttrValue(attrs, "size"),
color: parseAttrValue(attrs, "color")
});
return html ?? matched;
});
}
function replaceInlineIconSyntaxInLine(line: string): string {
let output = "";
let cursor = 0;
while (cursor < line.length) {
const start = line.indexOf("::", cursor);
if (start === -1) {
output += line.slice(cursor);
break;
}
if (line[start + 2] === " " || line[start + 2] === ":") {
output += line.slice(cursor, start + 2);
cursor = start + 2;
continue;
}
const end = line.indexOf("::", start + 2);
if (end === -1 || line[end - 1] === " ") {
output += line.slice(cursor, start + 2);
cursor = start + 2;
continue;
}
const content = line.slice(start + 2, end);
const icon = resolveInlineIcon(content);
const html = icon ? buildIconHtml(icon) : null;
output += line.slice(cursor, start);
output += html ?? line.slice(start, end + 2);
cursor = end + 2;
}
return output;
}
function replaceIconsInLine(line: string): string {
return replaceInlineIconSyntaxInLine(replaceIconTagsInLine(line));
}
export function replaceIconSyntaxInMarkdown(markdown: string): string {
if (!/(::[^:\s][\s\S]*?::|<(?:Icon|VPIcon)\b)/i.test(markdown)) {
return markdown;
}
const lines = markdown.split(/\r?\n/);
const out: string[] = [];
let fenceChar = "";
let fenceLength = 0;
for (const line of lines) {
if (fenceLength > 0) {
out.push(line);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(line)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = line.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
out.push(line);
continue;
}
out.push(replaceIconsInLine(line));
}
return out.join("\n");
}
import { normalizeIconifyId } from "../offlineIconify";
const ICONIFY_API_BASE = "https://api.iconify.design";
const iconSvgCache = new Map<string, Promise<string | null>>();
interface IconifyRequestResponse {
status: number;
text: string;
}
type IconifyRequestUrl = (options: {
url: string;
method: "GET";
}) => Promise<IconifyRequestResponse>;
let iconifyRequestUrl: IconifyRequestUrl | null = null;
export function setIconifyRequestUrl(requestUrl: IconifyRequestUrl): void {
iconifyRequestUrl = requestUrl;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function getIconifyUrl(iconId: string): string | null {
const normalized = normalizeIconifyId(iconId);
const separator = normalized.indexOf(":");
if (separator === -1) {
return null;
}
const prefix = normalized.slice(0, separator);
const name = normalized.slice(separator + 1);
if (!prefix || !name) {
return null;
}
return `${ICONIFY_API_BASE}/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`;
}
async function fetchIconifySvg(iconId: string): Promise<string | null> {
const normalized = normalizeIconifyId(iconId);
const cached = iconSvgCache.get(normalized);
if (cached) {
return cached;
}
const pending = (async (): Promise<string | null> => {
const url = getIconifyUrl(normalized);
if (!url) {
return null;
}
if (!iconifyRequestUrl) {
return null;
}
try {
const response = await iconifyRequestUrl({ url, method: "GET" });
if (response.status < 200 || response.status >= 300) {
return null;
}
const svg = response.text;
return /^\s*<svg[\s>]/i.test(svg) ? svg : null;
} catch {
return null;
}
})();
iconSvgCache.set(normalized, pending);
return pending;
}
export function createIconifySpanHtml(
iconId: string,
className = "vp-icon",
style?: string
): string {
const normalized = normalizeIconifyId(iconId);
const styleAttr = style ? ` style="${escapeHtml(style)}"` : "";
return `<span class="${escapeHtml(className)} ft-icon-online" data-vp-icon="${escapeHtml(normalized)}" aria-hidden="true"${styleAttr}></span>`;
}
export function prepareIconifyIconElement(element: HTMLElement, iconId: string): void {
const normalized = normalizeIconifyId(iconId);
element.dataset.vpIcon = normalized;
element.setAttribute("aria-hidden", "true");
element.classList.add("ft-icon-online");
}
function appendSvgMarkup(element: HTMLElement, svg: string): boolean {
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
const svgElement = parsed.documentElement;
if (svgElement.nodeName.toLowerCase() !== "svg") {
return false;
}
element.appendChild(element.ownerDocument.importNode(svgElement, true));
return true;
}
export async function processIconifyIcons(rootElement: HTMLElement): Promise<void> {
const elements = [
...(rootElement.matches("[data-vp-icon]") ? [rootElement] : []),
...Array.from(
rootElement.querySelectorAll<HTMLElement>("[data-vp-icon]")
)
];
await Promise.all(elements.map(async (element) => {
const iconId = element.dataset.vpIcon;
if (!iconId || element.dataset.vpIconLoaded === "1" || element.dataset.vpIconLoading === "1") {
return;
}
element.dataset.vpIconLoading = "1";
const svg = await fetchIconifySvg(iconId);
delete element.dataset.vpIconLoading;
if (!element.isConnected && !rootElement.contains(element)) {
return;
}
if (!svg) {
element.dataset.vpIconFailed = "1";
return;
}
element.empty();
if (!appendSvgMarkup(element, svg)) {
element.dataset.vpIconFailed = "1";
return;
}
element.dataset.vpIconLoaded = "1";
delete element.dataset.vpIconFailed;
}));
}
/** Public render pipeline surface (barrel). */
export type { BlockRenderContext, PlumeRenderSettings } from "./context";
export {
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./pipeline";
export {
scanCodeFenceTitles,
scanCodeFences,
decorateCodeBlockTitles,
decorateSubtreeCodeFences
} from "./code-fence";
export { gatherMasonryItems } from "../render";
export { renderTabbedContainer } from "./tabbed-container";
export { renderCollapseBlock } from "./blocks/collapse";
import type { BlockRenderContext } from "./context";
import { renderInnerMarkdown } from "./pipeline";
const INLINE_MD_BLOCK_TAGS = new Set([
"P",
"DIV",
"UL",
"OL",
"LI",
"PRE",
"BLOCKQUOTE",
"TABLE",
"HR",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6"
]);
function appendPhrasingFromRendered(host: HTMLElement, node: Node): void {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? "";
if (t) {
host.appendChild(document.createTextNode(t));
}
return;
}
if (!(node instanceof HTMLElement)) {
return;
}
if (node.tagName === "P" || INLINE_MD_BLOCK_TAGS.has(node.tagName)) {
for (const child of Array.from(node.childNodes)) {
appendPhrasingFromRendered(host, child);
}
return;
}
host.appendChild(node.cloneNode(true));
}
export async function renderInlineMarkdownInto(
host: HTMLElement,
text: string,
ctx: BlockRenderContext,
options?: { phrasingOnly?: boolean }
): Promise<void> {
const temp = document.createElement("div");
await renderInnerMarkdown(temp, text, ctx);
const phrasingHost =
options?.phrasingOnly === true
|| host.tagName === "P"
|| host.tagName === "SPAN";
host.empty();
if (phrasingHost) {
for (const node of Array.from(temp.childNodes)) {
appendPhrasingFromRendered(host, node);
}
return;
}
const elementChildren = Array.from(temp.children).filter(
(node): node is HTMLElement => node instanceof HTMLElement
);
if (elementChildren.length === 1 && elementChildren[0].tagName === "P") {
const paragraph = elementChildren[0];
while (paragraph.firstChild) {
host.appendChild(paragraph.firstChild);
}
return;
}
while (temp.firstChild) {
host.appendChild(temp.firstChild);
}
}
import { replaceBadgeTagsInMarkdown } from "./badge-transform";
import { replaceIconSyntaxInMarkdown } from "./icon-transform";
export function applyVuepressMarkdownTransforms(markdown: string): string {
return replaceIconSyntaxInMarkdown(replaceBadgeTagsInMarkdown(markdown));
}
import { parseAllBlocks, dedentStepBody } from "../parser";
import { renderPlumeMarkdown } from "../markdown/plume-markdown";
import type { ParsedBlock } from "../types";
import { invokeBlockRenderer } from "./block-registry";
import {
decorateCodeBlockTitles,
decorateSubtreeCodeFences,
scanCodeFenceTitles
} from "./code-fence";
import {
type BlockRenderContext,
toPlumeMarkdownContext
} from "./context";
export const BLOCK_PLACEHOLDER_CLASS = "vp-block-placeholder";
export const BLOCK_PLACEHOLDER_ATTR = "data-vp-block-id";
export function contentIsOnlyBlocksAndBlankLines(
content: string,
blocks: ParsedBlock[]
): boolean {
if (blocks.length === 0) {
return false;
}
const lines = content.split(/\r?\n/);
const inBlock = (line: number): boolean =>
blocks.some((b) => line >= b.startLine && line <= b.endLine);
for (let i = 0; i < lines.length; i += 1) {
if (!lines[i].trim()) {
continue;
}
if (inBlock(i)) {
continue;
}
return false;
}
return true;
}
export function pruneEmptyMarkdownNodes(root: HTMLElement): void {
for (const p of Array.from(root.querySelectorAll("p"))) {
if (p.closest(".vp-card-wrapper, .vp-file-tree, .vp-card-masonry, .vp-card-grid")) {
continue;
}
const text = p.textContent?.replace(/\u00a0/g, "").trim() ?? "";
if (text) {
continue;
}
if (p.querySelector("img, pre, code, table, ul, ol, blockquote, .vp-block-placeholder")) {
continue;
}
p.remove();
}
}
export async function renderPlumeBlocksInto(
container: HTMLElement,
blocks: ParsedBlock[],
ctx: BlockRenderContext
): Promise<void> {
for (const block of blocks) {
const host = document.createElement("div");
// Must be in the document before render: Obsidian setIcon() only paints on connected nodes.
container.appendChild(host);
try {
await invokeBlockRenderer(host, block, ctx);
} catch (err) {
console.error("[theme-plume] block render failed", err);
host.textContent = block.rawContent;
}
while (host.firstChild) {
container.insertBefore(host.firstChild, host);
}
host.remove();
}
}
async function renderMarkdownInto(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext
): Promise<void> {
if (ctx.renderMarkdown) {
await ctx.renderMarkdown(container, markdown);
return;
}
await renderPlumeMarkdown(container, markdown, toPlumeMarkdownContext(ctx));
}
export async function renderNestedMarkdownContent(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext,
options?: { dedent?: boolean }
): Promise<void> {
let content = markdown.replace(/^\n+|\n+$/g, "");
if (!content) {
return;
}
if (options?.dedent) {
content = dedentStepBody(content);
}
const blocks = parseAllBlocks(content, ctx.defaultIconMode);
if (blocks.length === 1) {
await invokeBlockRenderer(container, blocks[0], ctx);
pruneEmptyMarkdownNodes(container);
decorateSubtreeCodeFences(container, content, ctx.defaultIconMode);
return;
}
if (blocks.length > 0 && contentIsOnlyBlocksAndBlankLines(content, blocks)) {
await renderPlumeBlocksInto(container, blocks, ctx);
pruneEmptyMarkdownNodes(container);
return;
}
await renderInnerMarkdown(container, content, ctx);
}
export async function renderInnerMarkdown(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext
): Promise<void> {
const source = markdown.replace(/^\n+|\n+$/g, "");
if (!source) {
return;
}
const blocks = parseAllBlocks(source, ctx.defaultIconMode);
if (blocks.length === 0) {
await renderMarkdownInto(container, markdown, ctx);
decorateCodeBlockTitles(container, scanCodeFenceTitles(markdown), ctx.defaultIconMode);
pruneEmptyMarkdownNodes(container);
return;
}
const lines = source.split(/\r?\n/);
const placeholderById = new Map<string, ParsedBlock>();
const out: string[] = [];
let cursor = 0;
for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i];
for (let k = cursor; k < block.startLine; k += 1) {
out.push(lines[k]);
}
const opener = lines[block.startLine] ?? "";
const indentMatch = opener.match(/^[\t ]*/);
const indent = indentMatch?.[0] ?? "";
const id = `vp-blk-${i + 1}-${Math.random().toString(36).slice(2, 8)}`;
placeholderById.set(id, block);
if (out.length > 0 && out[out.length - 1].trim() !== "") {
out.push("");
}
out.push(
`${indent}<div class="${BLOCK_PLACEHOLDER_CLASS}" ${BLOCK_PLACEHOLDER_ATTR}="${id}"></div>`
);
cursor = block.endLine + 1;
if (cursor < lines.length && lines[cursor].trim() === "") {
out.push("");
cursor += 1;
}
}
for (let k = cursor; k < lines.length; k += 1) {
out.push(lines[k]);
}
container.empty();
const renderedMarkdown = out.join("\n");
await renderMarkdownInto(container, renderedMarkdown, ctx);
decorateCodeBlockTitles(container, scanCodeFenceTitles(renderedMarkdown), ctx.defaultIconMode);
const placeholders = Array.from(
container.querySelectorAll(`.${BLOCK_PLACEHOLDER_CLASS}[${BLOCK_PLACEHOLDER_ATTR}]`)
);
for (const node of placeholders) {
if (!(node instanceof HTMLElement)) continue;
const id = node.getAttribute(BLOCK_PLACEHOLDER_ATTR);
if (!id) continue;
const block = placeholderById.get(id);
if (!block) continue;
node.removeAttribute(BLOCK_PLACEHOLDER_ATTR);
node.classList.remove(BLOCK_PLACEHOLDER_CLASS);
node.empty();
try {
await invokeBlockRenderer(node, block, ctx);
decorateSubtreeCodeFences(node, block.rawContent, ctx.defaultIconMode);
} catch (err) {
console.error("[theme-plume] block render failed", err);
if (ctx.settings?.debugRender) {
node.createEl("p", { cls: "plume-render-error", text: `Failed: ${block.type}` });
}
node.textContent = block.rawContent;
}
}
pruneEmptyMarkdownNodes(container);
}
/** Shared tab persistence + cross-instance sync for tabs / code-tabs. */
export const TABS_SYNC_EVENT = "vpft:tabs-sync";
export const SHARED_TAB_ACTIVE = new Map<string, string>();
const TAB_STORE_KEY = "vp-plume-tab-store";
function readTabStore(): Record<string, string> {
try {
const raw = window.localStorage.getItem(TAB_STORE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, string>) : {};
} catch {
return {};
}
}
export function writeTabStoreValue(id: string, value: string): void {
try {
const store = readTabStore();
if (store[id] === value) return;
store[id] = value;
window.localStorage.setItem(TAB_STORE_KEY, JSON.stringify(store));
} catch {
/* localStorage may be unavailable */
}
}
export function getTabStoreValue(id: string): string | undefined {
return readTabStore()[id];
}
export function attachTabsKeyboardNav(
nav: HTMLElement,
buttons: Map<string, HTMLButtonElement>,
getActive: () => string,
activate: (value: string) => void
): void {
nav.addEventListener("keydown", (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (!target || target.getAttribute("role") !== "tab") return;
const values = Array.from(buttons.keys());
if (values.length === 0) return;
const current = values.indexOf(getActive());
const idx = current === -1 ? 0 : current;
let next = -1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = (idx + 1) % values.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (idx - 1 + values.length) % values.length;
break;
case "Home":
next = 0;
break;
case "End":
next = values.length - 1;
break;
default:
return;
}
event.preventDefault();
const nextValue = values[next];
activate(nextValue);
buttons.get(nextValue)?.focus();
});
}
import { setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import type { FileTreeIconMode, TabItem } from "../types";
import { hashString } from "../utils/hash";
import { prepareIconifyIconElement, processIconifyIcons } from "./iconify-online";
import {
attachTabsKeyboardNav,
getTabStoreValue,
SHARED_TAB_ACTIVE,
TABS_SYNC_EVENT,
writeTabStoreValue
} from "./tab-store";
export type TabbedVariant = "tabs" | "code-tabs";
export interface TabbedContainerOptions {
variant: TabbedVariant;
tabs: TabItem[];
sharedId?: string;
defaultIconMode: FileTreeIconMode;
/** When false, skip localStorage tab persistence. Default true. */
persistSelection?: boolean;
/** When true (default), render panel markdown only when the tab becomes active. */
lazyPanels?: boolean;
/** Bumps when the file is edited; forces panel re-render even if DOM nodes are reused. */
contentEpoch?: number;
renderPanel: (panel: HTMLElement, markdown: string) => Promise<void>;
}
function tabsContentRevision(tabs: TabItem[]): string {
return hashString(tabs.map((t) => `${t.value}\0${t.title}\0${t.content}`).join("\n"));
}
const VARIANT_CLASSES: Record<
TabbedVariant,
{ wrapper: string; nav: string; body: string; button: string; panel: string; btnPrefix: string; panelPrefix: string }
> = {
tabs: {
wrapper: "vp-tabs obsidian-vuepress-tabs",
nav: "vp-tabs-nav",
body: "vp-tabs-body",
button: "vp-tabs-tab",
panel: "vp-tabs-panel",
btnPrefix: "vp-tabs-btn",
panelPrefix: "vp-tabs-panel"
},
"code-tabs": {
wrapper: "vp-code-tabs obsidian-vuepress-code-tabs",
nav: "vp-code-tabs-nav",
body: "vp-code-tabs-body",
button: "vp-code-tab-nav",
panel: "vp-code-tab",
btnPrefix: "vp-code-tabs-btn",
panelPrefix: "vp-code-tabs-panel"
}
};
function decorateCodeTabButton(
button: HTMLButtonElement,
tab: TabItem,
mode: FileTreeIconMode
): void {
const iconHost = document.createElement("span");
iconHost.className = "vp-code-tab-icon ft-icon";
const desc = resolveNodeIcon(tab.title, "file", false, mode);
if (desc.colorClass) iconHost.classList.add(desc.colorClass);
if (desc.iconifyId) {
prepareIconifyIconElement(iconHost, desc.iconifyId);
void processIconifyIcons(iconHost);
} else {
setIcon(iconHost, desc.icon);
}
button.appendChild(iconHost);
const label = document.createElement("span");
label.className = "vp-code-tab-label";
label.textContent = tab.title;
button.appendChild(label);
}
/**
* Shared tabs / code-tabs renderer (nav, persistence, sync, panel markdown).
*/
export async function renderTabbedContainer(
container: HTMLElement,
options: TabbedContainerOptions
): Promise<void> {
const { variant, tabs, defaultIconMode, renderPanel } = options;
const persistSelection = options.persistSelection !== false;
const lazyPanels = options.lazyPanels !== false;
const cls = VARIANT_CLASSES[variant];
const sharedId = options.sharedId?.trim();
const tabByValue = new Map(tabs.map((t) => [t.value, t]));
const contentRevision = tabsContentRevision(tabs);
const contentEpoch = String(options.contentEpoch ?? 0);
const wrapper = document.createElement("div");
wrapper.className = cls.wrapper;
wrapper.dataset.plumeTabsRevision = contentRevision;
wrapper.dataset.plumeContentEpoch = contentEpoch;
container.appendChild(wrapper);
const nav = document.createElement("div");
nav.className = cls.nav;
nav.setAttribute("role", "tablist");
nav.setAttribute("aria-label", variant === "code-tabs" ? "代码选项卡" : "标签页");
wrapper.appendChild(nav);
const body = document.createElement("div");
body.className = cls.body;
wrapper.appendChild(body);
const explicitActive = tabs.find((t) => t.active)?.value;
const sharedActive = sharedId ? SHARED_TAB_ACTIVE.get(sharedId) : undefined;
const persistedActive =
sharedId && persistSelection ? getTabStoreValue(sharedId) : undefined;
const initialValue =
(sharedActive && tabs.some((t) => t.value === sharedActive) ? sharedActive : undefined)
?? (persistedActive && tabs.some((t) => t.value === persistedActive) ? persistedActive : undefined)
?? explicitActive
?? tabs[0]?.value;
const buttons = new Map<string, HTMLButtonElement>();
const panels = new Map<string, HTMLElement>();
let activeValue = initialValue ?? "";
let panelRenderSeq = 0;
const markPanelRendered = (panel: HTMLElement, value: string): void => {
panel.dataset.plumeTabRenderedRev = `${value}:${contentRevision}:${contentEpoch}`;
};
const isPanelRendered = (panel: HTMLElement, value: string): boolean =>
panel.dataset.plumeTabRenderedRev === `${value}:${contentRevision}:${contentEpoch}`
&& panel.childElementCount > 0
&& wrapper.dataset.plumeContentEpoch === contentEpoch;
const ensurePanelRendered = async (value: string): Promise<void> => {
const panel = panels.get(value);
const tab = tabByValue.get(value);
if (!(panel instanceof HTMLElement) || !tab) {
return;
}
if (!lazyPanels || isPanelRendered(panel, value)) {
return;
}
const token = String(++panelRenderSeq);
panel.dataset.plumeTabRenderToken = token;
panel.empty();
try {
await renderPanel(panel, tab.content);
if (
panel.dataset.plumeTabRenderToken !== token
|| !panel.isConnected
|| wrapper.dataset.plumeTabsRevision !== contentRevision
|| wrapper.dataset.plumeContentEpoch !== contentEpoch
) {
return;
}
markPanelRendered(panel, value);
} catch {
if (
panel.dataset.plumeTabRenderToken !== token
|| !panel.isConnected
|| wrapper.dataset.plumeTabsRevision !== contentRevision
|| wrapper.dataset.plumeContentEpoch !== contentEpoch
) {
return;
}
panel.empty();
panel.textContent = tab.content;
markPanelRendered(panel, value);
}
};
const setActive = (value: string, emit: boolean): void => {
if (!buttons.has(value)) {
return;
}
activeValue = value;
for (const [v, btn] of buttons) {
const isActive = v === value;
btn.classList.toggle("active", isActive);
btn.setAttribute("aria-selected", isActive ? "true" : "false");
btn.tabIndex = isActive ? 0 : -1;
btn.setAttribute("aria-disabled", isActive ? "true" : "false");
}
for (const [v, panel] of panels) {
const isActive = v === value;
if (isActive) {
panel.classList.add("active");
// 切换时总是刷新内容
panel.empty();
const tab = tabByValue.get(v);
if (tab) {
void renderPanel(panel, tab.content);
}
// 动画:先透明,后淡入
} else {
panel.classList.remove("active");
}
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
panel.setAttribute("aria-expanded", isActive ? "true" : "false");
}
if (lazyPanels) {
void ensurePanelRendered(value);
}
if (sharedId) {
SHARED_TAB_ACTIVE.set(sharedId, value);
if (persistSelection) {
writeTabStoreValue(sharedId, value);
}
if (emit) {
document.dispatchEvent(
new CustomEvent(TABS_SYNC_EVENT, {
detail: { id: sharedId, value, source: wrapper }
})
);
}
}
};
for (const tab of tabs) {
const buttonId = `${cls.btnPrefix}-${Math.random().toString(36).slice(2, 10)}`;
const panelId = `${cls.panelPrefix}-${Math.random().toString(36).slice(2, 10)}`;
const button = document.createElement("button");
button.type = "button";
button.className = cls.button;
button.setAttribute("role", "tab");
button.id = buttonId;
button.setAttribute("aria-controls", panelId);
button.setAttribute("tabindex", "-1"); // 默认非激活
if (variant === "code-tabs") {
decorateCodeTabButton(button, tab, defaultIconMode);
} else {
button.textContent = tab.title;
}
nav.appendChild(button);
buttons.set(tab.value, button);
const panel = document.createElement("section");
panel.className = cls.panel;
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", buttonId);
panel.setAttribute("tabindex", "0");
body.appendChild(panel);
panels.set(tab.value, panel);
button.addEventListener("click", () => {
if (activeValue === tab.value) return;
setActive(tab.value, true);
});
}
if (sharedId) {
const onSync = (event: Event): void => {
if (!wrapper.isConnected) {
document.removeEventListener(TABS_SYNC_EVENT, onSync as EventListener);
return;
}
const detail = (event as CustomEvent<{ id?: string; value?: string; source?: HTMLElement }>)
.detail;
if (!detail || detail.id !== sharedId || detail.source === wrapper) return;
if (!detail.value || detail.value === activeValue || !buttons.has(detail.value)) return;
setActive(detail.value, false);
};
document.addEventListener(TABS_SYNC_EVENT, onSync as EventListener);
}
attachTabsKeyboardNav(nav, buttons, () => activeValue, (value) => setActive(value, true));
if (initialValue) {
setActive(initialValue, false);
}
if (lazyPanels) {
if (initialValue) {
await ensurePanelRendered(initialValue);
}
return;
}
await Promise.all(
tabs.map(async (tab) => {
const panel = panels.get(tab.value);
if (!panel) return;
try {
await renderPanel(panel, tab.content);
markPanelRendered(panel, tab.value);
} catch {
panel.empty();
panel.textContent = tab.content;
markPanelRendered(panel, tab.value);
}
})
);
}
export type FileTreeIconMode = "simple" | "colored";
export interface FileTreeNodeProps {
filename: string;
filepath?: string;
comment?: string;
focus?: boolean;
expanded?: boolean;
type: "folder" | "file";
diff?: "add" | "remove";
level?: number;
}
export interface FileTreeNode extends FileTreeNodeProps {
level: number;
children: FileTreeNode[];
}
export interface FileTreeContainerAttrs {
title?: string;
icon?: FileTreeIconMode;
}
export interface CodeTreeContainerAttrs extends FileTreeContainerAttrs {
height?: string;
entry?: string;
}
export interface CodeTreeFileItem {
filepath: string;
language: string;
content: string;
active?: boolean;
}
export interface TabsContainerAttrs {
id?: string;
}
export interface CodeTabsContainerAttrs {
id?: string;
}
export interface TabItem {
title: string;
value: string;
content: string;
active?: boolean;
}
export type PromptContainerType = "note" | "info" | "tip" | "warning" | "caution" | "details" | "important";
export interface PromptContainerAttrs {
type: PromptContainerType;
title?: string;
}
export interface CardContainerAttrs {
title?: string;
icon?: string;
}
export interface CardGridContainerAttrs {
cols?: string;
}
export interface CardMasonryContainerAttrs {
cols?: string;
gap?: string;
}
export interface RepoCardContainerAttrs {
repo: string;
provider?: "github" | "gitee";
fullname?: boolean;
}
export interface LinkCardContainerAttrs {
href: string;
title?: string;
icon?: string;
description?: string;
target?: string;
rel?: string;
}
export interface ImageCardContainerAttrs {
image: string;
title?: string;
description?: string;
href?: string;
author?: string;
date?: string;
width?: string;
center?: boolean;
}
export interface FieldContainerAttrs {
name: string;
type?: string;
required?: boolean;
optional?: boolean;
deprecated?: boolean;
default?: string;
}
// field-group is a structural wrapper; no attributes.
export type FieldGroupContainerAttrs = Record<string, never>;
export interface FlexContainerAttrs {
align?: "start" | "end" | "center";
justify?: "between" | "around" | "center";
column?: boolean;
wrap?: boolean;
gap?: string;
}
export type AlignContainerType = "left" | "center" | "right";
export interface AlignContainerAttrs {
align: AlignContainerType;
}
export interface WindowContainerAttrs {
title?: string;
height?: string;
gap?: string;
noPadding?: boolean;
}
export interface ChatContainerAttrs {
title?: string;
}
export interface RepoCardInfo {
name: string;
fullName: string;
description: string;
url: string;
stars: number;
forks: number;
language: string;
languageColor: string;
archived: boolean;
visibility: "Private" | "Public";
template: boolean;
ownerType: "User" | "Organization";
license: { name: string; url?: string } | null;
}
export interface CollapseContainerAttrs {
accordion?: boolean;
expand?: boolean;
}
export type TimelinePlacement = "left" | "right" | "between";
export type TimelineLineStyle = "solid" | "dashed" | "dotted";
export type TimelineItemType =
| "info"
| "tip"
| "success"
| "warning"
| "danger"
| "caution"
| "important";
export interface TimelineContainerAttrs {
horizontal?: boolean;
card?: boolean;
placement?: TimelinePlacement;
line?: TimelineLineStyle;
}
export interface TimelineItemMeta {
time?: string;
type?: string;
icon?: string;
color?: string;
line?: TimelineLineStyle;
card?: boolean;
placement?: "left" | "right";
}
export interface FileTreePluginSettings {
defaultIconMode: FileTreeIconMode;
/** Remember selected tab per `::: tabs#id` / `::: code-tabs#id` in localStorage. */
persistTabSelection: boolean;
/** Defer collapse panel body render until the panel is opened. */
collapseLazyBodies: boolean;
/** Render only the active tab panel; others load on first switch. */
tabsLazyPanels: boolean;
/** Log render failures and show debug hints in preview. */
debugRender: boolean;
}
export const DEFAULT_SETTINGS: FileTreePluginSettings = {
defaultIconMode: "colored",
persistTabSelection: true,
collapseLazyBodies: true,
tabsLazyPanels: true,
debugRender: false
};
export type BlockType =
| "file-tree"
| "code-tree"
| "code-tree-embed"
| "tabs"
| "code-tabs"
| "steps"
| "prompt"
| "collapse"
| "card"
| "card-grid"
| "card-masonry"
| "repo-card"
| "link-card"
| "image-card"
| "field"
| "field-group"
| "flex"
| "window"
| "chat"
| "timeline"
| "align";
export interface ParsedBlock {
type: BlockType;
/** 0-based, inclusive */
startLine: number;
/** 0-based, inclusive */
endLine: number;
/** raw content lines joined by `\n`, NOT including the open/close markers */
rawContent: string;
/** marker length for `:::` (3+) blocks; 0 for embed */
markerLen: number;
attrs:
| FileTreeContainerAttrs
| CodeTreeContainerAttrs
| TabsContainerAttrs
| CodeTabsContainerAttrs
| PromptContainerAttrs
| CollapseContainerAttrs
| CardContainerAttrs
| CardGridContainerAttrs
| CardMasonryContainerAttrs
| RepoCardContainerAttrs
| LinkCardContainerAttrs
| ImageCardContainerAttrs
| FieldContainerAttrs
| FieldGroupContainerAttrs
| FlexContainerAttrs
| AlignContainerAttrs
| WindowContainerAttrs
| ChatContainerAttrs
| TimelineContainerAttrs
| { dirPath: string } & CodeTreeContainerAttrs
| Record<string, never>;
}
/** Fast non-crypto string hash for cache / revision keys. */
export function hashString(input: string): string {
let h = 2166136261;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(36);
}
添加配置
插件源码
plume-complex-test.md
vuepressFileIcons.ts
icons.ts
plume-markdown.ts
offlineIconify.ts
parser.test.ts
parser.ts
code-fence-titles.ts
preview-pipeline.ts
preview-sync.ts
render.ts
badge-transform.ts
block-registry.ts
collapse.ts
code-fence.ts
context.ts
icon-transform.test.ts
icon-transform.ts
iconify-online.ts
index.ts
inline.ts
markdown-transforms.ts
pipeline.ts
tab-store.ts
tabbed-container.ts
types.ts
hash.ts
import type {
CardContainerAttrs,
CardGridContainerAttrs,
CardMasonryContainerAttrs,
RepoCardContainerAttrs,
LinkCardContainerAttrs,
ImageCardContainerAttrs,
FieldContainerAttrs,
FieldGroupContainerAttrs,
FlexContainerAttrs,
AlignContainerAttrs,
WindowContainerAttrs,
ChatContainerAttrs,
CollapseContainerAttrs,
CodeTreeContainerAttrs,
CodeTreeFileItem,
FileTreeContainerAttrs,
FileTreeIconMode,
FileTreeNode,
FileTreeNodeProps,
ParsedBlock,
PromptContainerAttrs,
TabItem,
TabsContainerAttrs,
CodeTabsContainerAttrs,
TimelineContainerAttrs,
TimelineLineStyle,
TimelinePlacement,
AlignContainerType
} from "./types";
const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/;
const ELLIPSIS = "\u2026";
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_TAB_MARKER = /^\s*@tab(?::active)?\s*(.*)$/i;
// HTML 组件标签正则
type HtmlComponentTag = "Card" | "CardGrid" | "CardMasonry" | "RepoCard" | "LinkCard" | "ImageCard";
interface HtmlComponentOpen {
attrs: string;
afterOpen: string;
selfClosing: boolean;
}
interface HtmlComponentBlock {
rawContent: string;
endLine: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function matchHtmlComponentOpen(line: string, tag: HtmlComponentTag): HtmlComponentOpen | null {
const name = escapeRegExp(tag);
const selfClosing = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)\\/?>\\s*$`, "i"));
if (selfClosing && /\/>\s*$/.test(line)) {
return {
attrs: selfClosing[1] ?? "",
afterOpen: "",
selfClosing: true
};
}
const open = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)>(.*)$`, "i"));
if (!open) {
return null;
}
return {
attrs: open[1] ?? "",
afterOpen: open[2] ?? "",
selfClosing: false
};
}
function splitAtHtmlComponentClose(line: string, tag: HtmlComponentTag): { before: string } | null {
const match = line.match(new RegExp(`^(.*?)<\\/${escapeRegExp(tag)}>\\s*$`, "i"));
if (!match) {
return null;
}
return { before: match[1] ?? "" };
}
function collectHtmlComponentBlock(
lines: string[],
startLine: number,
tag: HtmlComponentTag,
open: HtmlComponentOpen
): HtmlComponentBlock | null {
if (open.selfClosing) {
return { rawContent: "", endLine: startLine };
}
const content: string[] = [];
const firstClose = splitAtHtmlComponentClose(open.afterOpen, tag);
if (firstClose) {
if (firstClose.before.trim()) {
content.push(firstClose.before);
}
return { rawContent: content.join("\n"), endLine: startLine };
}
if (open.afterOpen.trim()) {
content.push(open.afterOpen);
}
let fenceChar = "";
let fenceLength = 0;
let sameTagDepth = 0;
for (let cursor = startLine + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
if (fenceLength > 0) {
content.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = current.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
content.push(current);
continue;
}
const nestedOpen = matchHtmlComponentOpen(current, tag);
if (nestedOpen && !nestedOpen.selfClosing) {
sameTagDepth += 1;
content.push(current);
continue;
}
const close = splitAtHtmlComponentClose(current, tag);
if (close) {
if (sameTagDepth === 0) {
if (close.before.trim()) {
content.push(close.before);
}
return { rawContent: content.join("\n"), endLine: cursor };
}
sameTagDepth -= 1;
content.push(current);
continue;
}
content.push(current);
}
return null;
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
function parseLinkCardAttrs(attrsStr: string): LinkCardContainerAttrs {
const attrs: LinkCardContainerAttrs = { href: "" };
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const target = parseAttrValue(attrsStr, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(attrsStr, "rel");
if (rel) attrs.rel = rel;
return attrs;
}
function parseCardAttrs(attrsStr: string): CardContainerAttrs {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
return attrs;
}
function parseCardGridAttrs(attrsStr: string): CardGridContainerAttrs {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
return attrs;
}
function parseCardMasonryAttrs(attrsStr: string): CardMasonryContainerAttrs {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(attrsStr, "gap");
if (gap) attrs.gap = gap;
return attrs;
}
function parseRepoCardAttrs(attrsStr: string): RepoCardContainerAttrs | null {
const repo = parseAttrValue(attrsStr, "repo");
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(attrsStr, "provider");
if (provider === "github" || provider === "gitee") attrs.provider = provider;
if (/(^|\s)fullname(?:\s|=|$)/i.test(attrsStr)) {
const fullname = parseAttrValue(attrsStr, "fullname");
attrs.fullname = fullname ? fullname !== "false" : true;
}
return attrs;
}
function parseImageCardAttrs(attrsStr: string): ImageCardContainerAttrs {
const attrs: ImageCardContainerAttrs = { image: "" };
const image = parseAttrValue(attrsStr, "image");
if (image) attrs.image = image;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const author = parseAttrValue(attrsStr, "author");
if (author) attrs.author = author;
const date = parseAttrValue(attrsStr, "date");
if (date) attrs.date = date;
const width = parseAttrValue(attrsStr, "width");
if (width) attrs.width = width;
const center = parseAttrValue(attrsStr, "center");
if (center !== undefined) attrs.center = center !== "false";
else if (/(^|\s)center(\s|$)/.test(attrsStr)) attrs.center = true;
return attrs;
}
export function normalizeCodeTreePath(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.replace(/^\/+/, "");
}
function removeEndingSlash(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
}
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
const trimmed = content.trimEnd();
if (!trimmed) {
return [];
}
const lines = trimmed.split(/\r?\n/);
const root: FileTreeNode = {
filename: "",
type: "folder",
expanded: true,
level: -1,
children: []
};
const stack: FileTreeNode[] = [root];
const initialIndent = lines[0]?.match(/^\s*/)?.[0].length ?? 0;
for (const line of lines) {
const match = line.match(/^(\s*)-(.*)$/);
if (!match) {
continue;
}
const level = Math.floor((match[1].length - initialIndent) / 2);
const info = match[2].trim();
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
const parent = stack[stack.length - 1];
if (!parent) {
continue;
}
const node: FileTreeNode = {
level,
children: [],
...parseFileTreeNodeInfo(info)
};
parent.children.push(node);
stack.push(node);
}
return root.children;
}
export function parseFileTreeNodeInfo(info: string): FileTreeNodeProps {
let filename = "";
let comment = "";
let focus = false;
let expanded: boolean | undefined = true;
let type: "folder" | "file" = "file";
let diff: "add" | "remove" | undefined;
if (info.startsWith("++")) {
info = info.slice(2).trim();
diff = "add";
} else if (info.startsWith("--")) {
info = info.slice(2).trim();
diff = "remove";
}
info = info.replace(RE_FOCUS, (_matched, focusName: string) => {
filename = focusName;
focus = true;
return "";
});
if (filename === "" && !focus) {
const commentStart = info.indexOf("#");
filename = info.slice(0, commentStart === -1 ? info.length : commentStart).trim();
info = commentStart === -1 ? "" : info.slice(commentStart);
}
comment = info.trim();
if (filename.endsWith("/")) {
type = "folder";
expanded = false;
filename = removeEndingSlash(filename);
}
return {
filename,
comment,
focus,
expanded,
type,
diff
};
}
export function parseContainerHeader(line: string, fallbackIcon: FileTreeIconMode): FileTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*file-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: FileTreeContainerAttrs = {
icon: fallbackIcon
};
const attrRegex = /([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g;
let attrMatch: RegExpExecArray | null;
while ((attrMatch = attrRegex.exec(tail)) !== null) {
const key = attrMatch[1];
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (key === "title") {
attrs.title = value;
}
if (key === "icon" && (value === "simple" || value === "colored")) {
attrs.icon = value;
}
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseCodeTreeContainerHeader(line: string, fallbackIcon: FileTreeIconMode): CodeTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: CodeTreeContainerAttrs = {
icon: fallbackIcon
};
const title = parseAttrValue(tail, "title");
if (title) {
attrs.title = title;
}
const entry = parseAttrValue(tail, "entry");
if (entry) {
attrs.entry = normalizeCodeTreePath(entry);
}
const height = parseAttrValue(tail, "height");
if (height) {
attrs.height = height;
}
const icon = parseAttrValue(tail, "icon");
if (icon === "simple" || icon === "colored") {
attrs.icon = icon;
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseTabsContainerHeader(line: string): TabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*tabs\b(.*)$/i);
if (!match) {
return null;
}
const tail = (match[1] ?? "").trim();
const attrs: TabsContainerAttrs = {};
const idMatch = tail.match(/^#([^\s#]+)/);
if (idMatch?.[1]) {
attrs.id = idMatch[1];
} else {
const idAttr = parseAttrValue(tail, "id");
if (idAttr) {
attrs.id = idAttr;
}
}
return attrs;
}
export function isFileTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*file-tree\b/i.test(text.trim());
}
export function isCodeTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tree\b/i.test(text.trim());
}
export function isTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*tabs\b/i.test(text.trim());
}
export function parseCodeTabsContainerHeader(line: string): CodeTabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tabs\b(.*)$/i);
if (!match) {
return null;
}
const rest = (match[1] ?? "").trim();
const attrs: CodeTabsContainerAttrs = {};
// Syntax: ::: code-tabs#myid (hash form, matching original vuepress-theme-plume)
const hashMatch = rest.match(/^#([\w-]+)/);
if (hashMatch) {
attrs.id = hashMatch[1];
} else {
// Fallback: id="..." form for parity with other containers.
const id = parseAttrValue(rest, "id");
if (id) attrs.id = id;
}
return attrs;
}
export function isCodeTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tabs\b/i.test(text.trim());
}
export function isStepsOpenMarker(text: string): boolean {
return /^:{3,}\s*steps\b/i.test(text.trim());
}
export interface ParsedStepItem {
/** Markdown body of the step (title line + content), without the leading `N.` marker */
body: string;
}
const RE_STEP_LINE = /^\s*\d+[.)]\s+/;
/**
* Split steps container body into items. VuePress relies on markdown `ol`, but
* Obsidian breaks lists when `:::` containers appear inside `li` — we render
* one `<li>` per step and run markdown inside each item instead.
*/
export function parseStepsRawContent(rawContent: string): ParsedStepItem[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const lines = text.split(/\r?\n/);
const chunks: string[] = [];
let current: string[] = [];
const pushChunk = (): void => {
if (current.length === 0) {
return;
}
chunks.push(current.join("\n"));
current = [];
};
for (const line of lines) {
if (RE_STEP_LINE.test(line)) {
pushChunk();
current.push(line);
continue;
}
if (current.length > 0) {
current.push(line);
}
}
pushChunk();
const items: ParsedStepItem[] = [];
for (const chunk of chunks) {
const chunkLines = chunk.split(/\r?\n/);
if (chunkLines.length === 0) {
continue;
}
chunkLines[0] = chunkLines[0].replace(/^\s*\d+[.)]\s*/, "");
const body = chunkLines.join("\n").trim();
items.push({ body });
}
return items;
}
/**
* Remove common list-item indentation so fenced ``` inside steps parse correctly
* in Obsidian (indented fences are not recognized as code blocks).
*/
export function dedentStepBody(body: string): string {
const lines = body.split(/\r?\n/);
const positiveIndents: number[] = [];
for (const line of lines) {
if (!line.trim()) {
continue;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len > 0) {
positiveIndents.push(len);
}
}
if (positiveIndents.length === 0) {
return body;
}
const min = Math.min(...positiveIndents);
return lines
.map((line) => {
if (!line.trim()) {
return line;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len >= min) {
return line.slice(min);
}
return line;
})
.join("\n");
}
export interface CollapseItem {
titleLines: string[];
body: string;
expand?: boolean;
}
export interface ParsedCollapseContent {
/** Markdown before the first list item (optional intro). */
preamble: string;
items: CollapseItem[];
}
function buildCollapseItem(rawLines: string[]): CollapseItem {
while (rawLines.length && rawLines[0].trim() === "") rawLines.shift();
while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop();
if (rawLines.length === 0) {
return { titleLines: [], body: "", expand: undefined };
}
const titleLines = [rawLines[0]];
let bodyStart = 1;
// 允许空行后正文(正文不缩进也能识别)
while (bodyStart < rawLines.length && rawLines[bodyStart].trim() === "") {
bodyStart += 1;
}
// 如果正文首行不是新列表项,则全部视为正文
let bodyRaw = "";
if (bodyStart < rawLines.length) {
bodyRaw = rawLines.slice(bodyStart).join("\n");
}
let expand: boolean | undefined;
titleLines[0] = titleLines[0].replace(/^:([+-])\s*/, (_, flag: string) => {
expand = flag === "+";
return "";
});
return {
titleLines,
body: dedentStepBody(bodyRaw),
expand
};
}
/**
* Parse `::: collapse` list body into optional preamble + panel items.
*/
export function parseCollapseRawContent(rawContent: string): ParsedCollapseContent {
const lines = rawContent.replace(/\r\n/g, "\n").split("\n");
const preambleLines: string[] = [];
const items: CollapseItem[] = [];
let current: string[] | null = null;
const itemStart = /^(?:[-*+]\s+|\d+[.)]\s+)/;
for (const line of lines) {
if (itemStart.test(line)) {
if (current) {
items.push(buildCollapseItem(current));
}
current = [line.replace(itemStart, "")];
continue;
}
if (current) {
current.push(line);
} else {
preambleLines.push(line);
}
}
if (current) {
items.push(buildCollapseItem(current));
}
const filtered = items.filter(
(item) => item.titleLines.length > 0 || item.body.trim().length > 0
);
const preamble = dedentStepBody(preambleLines.join("\n").replace(/^\n+|\n+$/g, ""));
if (filtered.length > 0) {
return { preamble, items: filtered };
}
const trimmed = rawContent.replace(/^\n+|\n+$/g, "");
if (!trimmed) {
return { preamble: "", items: [] };
}
return {
preamble: "",
items: [{ titleLines: [], body: dedentStepBody(trimmed) }]
};
}
/** Split flex body into separate block-level segments (e.g. two tables). */
export function splitFlexSegments(rawContent: string): string[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const parts = text
.split(/\n(?:[ \t]*\n)+/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length > 0 ? parts : [text];
}
/** Parse flex header flags the same way as vuepress-plugin-md-power alignPlugin. */
export function parseFlexContainerAttrs(rest: string): FlexContainerAttrs {
const attrs: FlexContainerAttrs = {};
const gap = parseAttrValue(rest, "gap");
if (gap) {
attrs.gap = gap;
}
const flagSource = rest
.replace(/gap\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi, " ")
.trim()
.toLowerCase();
const flags = flagSource.split(/\s+/).filter(Boolean);
for (const flag of flags) {
if (flag === "start") {
attrs.align = "start";
} else if (flag === "end") {
attrs.align = "end";
} else if (flag === "center") {
attrs.align = "center";
} else if (flag === "between") {
attrs.justify = "between";
} else if (flag === "around") {
attrs.justify = "around";
} else if (flag === "column") {
attrs.column = true;
} else if (flag === "wrap") {
attrs.wrap = true;
}
}
if (flags.includes("center") && !attrs.justify) {
attrs.justify = "center";
}
return attrs;
}
export function parsePromptContainerHeader(line: string): (PromptContainerAttrs & { markerLen: number }) | null {
const match = line.trim().match(/^(:{3,})\s*(note|info|tip|warning|caution|details|important)\b(.*)$/i);
if (!match) {
return null;
}
const markerLen = match[1]?.length ?? 0;
const type = (match[2] ?? "").toLowerCase() as PromptContainerAttrs["type"];
const title = (match[3] ?? "").trim() || undefined;
return {
type,
title,
markerLen
};
}
export function isPromptContainerOpenMarker(text: string): boolean {
return /^:{3,}\s*(note|info|tip|warning|caution|details|important)\b/i.test(text.trim());
}
export function isFileTreeCloseMarker(text: string): boolean {
return text.trim() === ":::";
}
export function parseCodeTreeRawContent(content: string): CodeTreeFileItem[] {
const lines = content.split(/\r?\n/);
const files: CodeTreeFileItem[] = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const openMatch = line.match(RE_CODE_FENCE_OPEN);
if (!openMatch) {
continue;
}
const fence = openMatch[2];
const markerChar = fence[0];
const markerLength = fence.length;
const info = (openMatch[3] ?? "").trim();
const title = parseAttrValue(info, "title");
const isActive = /(?:^|\s):active(?:\s|$)/.test(info);
const languageToken = info.split(/\s+/)[0] ?? "";
const language = languageToken && !languageToken.startsWith(":") ? languageToken : "text";
const body: string[] = [];
let closed = false;
for (let cursor = lineIndex + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
const closeRegex = new RegExp(`^\\s*${markerChar}{${markerLength},}\\s*$`);
if (closeRegex.test(current)) {
lineIndex = cursor;
closed = true;
break;
}
body.push(current);
}
if (!closed) {
break;
}
if (!title) {
continue;
}
const filepath = normalizeCodeTreePath(title);
if (!filepath) {
continue;
}
files.push({
filepath,
language,
content: body.join("\n"),
active: isActive
});
}
return files;
}
function parseTabMarker(line: string): {
title: string;
value: string;
active: boolean;
} | null {
const match = line.match(RE_TAB_MARKER);
if (!match) {
return null;
}
const active = /@tab:active/i.test(line);
const raw = (match[1] ?? "").trim();
const hashIndex = raw.indexOf("#");
let title = raw;
let value = "";
if (hashIndex >= 0) {
title = raw.slice(0, hashIndex).trim();
value = raw.slice(hashIndex + 1).trim();
}
title ||= value;
value ||= title;
if (!title && !value) {
return null;
}
return {
title,
value,
active
};
}
export function parseTabsRawContent(content: string): TabItem[] {
const lines = content.split(/\r?\n/);
const tabs: TabItem[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const marker = parseTabMarker(lines[lineIndex]);
if (!marker) {
lineIndex += 1;
continue;
}
const body: string[] = [];
lineIndex += 1;
let fenceChar = "";
let fenceLength = 0;
while (lineIndex < lines.length) {
const current = lines[lineIndex];
if (fenceLength > 0) {
body.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
lineIndex += 1;
continue;
}
const openMatch = current.match(RE_CODE_FENCE_OPEN);
if (openMatch) {
const fence = openMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
body.push(current);
lineIndex += 1;
continue;
}
if (parseTabMarker(current)) {
break;
}
body.push(current);
lineIndex += 1;
}
tabs.push({
title: marker.title,
value: marker.value,
active: marker.active,
content: body.join("\n").replace(/^\n+|\n+$/g, "")
});
}
for (let index = 0; index < tabs.length; index += 1) {
const tab = tabs[index];
if (!tab.title) {
tab.title = `Tab ${index + 1}`;
}
if (!tab.value) {
tab.value = tab.title;
}
}
return tabs;
}
export function parseCodeTreeFileNodes(files: CodeTreeFileItem[]): FileTreeNode[] {
const nodes: FileTreeNode[] = [];
for (const file of files) {
const normalized = normalizeCodeTreePath(file.filepath);
if (!normalized) {
continue;
}
const parts = normalized.split("/").filter(Boolean);
let children = nodes;
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const isFile = index === parts.length - 1;
let node = children.find((item) => {
return item.filename === part;
});
if (!node) {
node = {
filename: part,
filepath: isFile ? normalized : undefined,
type: isFile ? "file" : "folder",
expanded: true,
level: index,
children: []
};
children.push(node);
}
if (isFile) {
node.type = "file";
node.filepath = normalized;
continue;
}
node.type = "folder";
node.expanded = true;
children = node.children;
}
}
return nodes;
}
function listItemInlineText(item: HTMLLIElement): string {
const parts: string[] = [];
for (const node of Array.from(item.childNodes)) {
if (node instanceof HTMLElement && (node.tagName === "UL" || node.tagName === "OL")) {
break;
}
if (node instanceof HTMLElement && node.tagName === "STRONG") {
const strongText = (node.textContent ?? "").trim();
parts.push(`**${strongText}**`);
continue;
}
parts.push(node.textContent ?? "");
}
return parts.join("").replace(/\r?\n/g, " ").trim();
}
export function listElementToRawLines(list: HTMLElement, level = 0): string[] {
const lines: string[] = [];
for (const child of Array.from(list.children)) {
if (!(child instanceof HTMLLIElement)) {
continue;
}
const info = listItemInlineText(child);
if (info) {
lines.push(`${" ".repeat(level)}- ${info}`);
}
const nestedLists = Array.from(child.children).filter((nested) => {
return nested.tagName === "UL" || nested.tagName === "OL";
});
for (const nestedList of nestedLists) {
lines.push(...listElementToRawLines(nestedList as HTMLElement, level + 1));
}
}
return lines;
}
export function fileTreeToCMDText(nodes: FileTreeNode[], prefix = ""): string {
let content = prefix ? "" : ".\n";
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const lead = i === nodes.length - 1 ? "└── " : "├── ";
content += `${prefix}${lead}${node.filename}\n`;
const childNodes = node.children.filter((child) => {
return child.filename !== ELLIPSIS && child.filename !== "...";
});
if (childNodes.length > 0) {
const childPrefix = prefix + (i === nodes.length - 1 ? " " : "│ ");
content += fileTreeToCMDText(childNodes, childPrefix);
}
}
return content;
}
// ---------------------------------------------------------------------------
// Unified block scanner
// ---------------------------------------------------------------------------
const RE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_DEFAULT_ICON_FALLBACK: FileTreeIconMode = "colored";
const CODE_TREE_EMBED_RE_LINE = /^\s*@\[code-tree([^\]]*)\]\(([^)]*)\)\s*$/i;
interface ContainerHeaderInfo {
type: "file-tree" | "code-tree" | "tabs" | "code-tabs" | "steps" | "prompt" | "collapse" | "card" | "card-grid" | "card-masonry" | "repo-card" | "link-card" | "image-card" | "field" | "field-group" | "flex" | "align" | "window" | "chat" | "timeline";
markerLen: number;
attrs:
| FileTreeContainerAttrs
| CodeTreeContainerAttrs
| TabsContainerAttrs
| CodeTabsContainerAttrs
| PromptContainerAttrs
| CollapseContainerAttrs
| CardContainerAttrs
| CardGridContainerAttrs
| CardMasonryContainerAttrs
| RepoCardContainerAttrs
| LinkCardContainerAttrs
| ImageCardContainerAttrs
| FieldContainerAttrs
| FieldGroupContainerAttrs
| FlexContainerAttrs
| AlignContainerAttrs
| WindowContainerAttrs
| ChatContainerAttrs
| TimelineContainerAttrs;
}
function detectContainerOpen(line: string, fallbackIcon: FileTreeIconMode): ContainerHeaderInfo | null {
const trimmed = line.trim();
const match = trimmed.match(/^(:{3,})\s*([a-zA-Z][\w-]*)\b(.*)$/);
if (!match) {
return null;
}
const markerLen = match[1].length;
const keyword = match[2].toLowerCase();
const rest = match[3] ?? "";
if (keyword === "file-tree") {
const attrs = parseContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "file-tree", markerLen, attrs };
}
if (keyword === "code-tree") {
const attrs = parseCodeTreeContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "code-tree", markerLen, attrs };
}
if (keyword === "tabs") {
const attrs = parseTabsContainerHeader(line);
if (!attrs) return null;
return { type: "tabs", markerLen, attrs };
}
if (keyword === "code-tabs") {
const attrs = parseCodeTabsContainerHeader(line);
if (!attrs) return null;
return { type: "code-tabs", markerLen, attrs };
}
if (keyword === "steps") {
return { type: "steps", markerLen, attrs: {} as TabsContainerAttrs };
}
if (keyword === "collapse") {
const attrs: CollapseContainerAttrs = {};
if (/(^|\s)accordion(\s|$|=)/i.test(rest)) {
const accordionVal = parseAttrValue(rest, "accordion");
attrs.accordion = accordionVal ? accordionVal !== "false" : true;
}
if (/(^|\s)expand(\s|$|=)/i.test(rest)) {
const expandVal = parseAttrValue(rest, "expand");
attrs.expand = expandVal ? expandVal !== "false" : true;
}
return { type: "collapse", markerLen, attrs };
}
if (keyword === "card") {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
return { type: "card", markerLen, attrs };
}
if (keyword === "card-grid") {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
return { type: "card-grid", markerLen, attrs };
}
if (keyword === "card-masonry") {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(rest, "gap");
if (gap) attrs.gap = gap;
return { type: "card-masonry", markerLen, attrs };
}
if (keyword === "repo-card") {
// Accept either `repo="owner/name"` or a positional `owner/name` after
// the keyword (matches the convention used by `prompt` containers).
let repo = parseAttrValue(rest, "repo") ?? "";
if (!repo) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && positional.includes("/") && !positional.includes("=")) {
repo = positional;
}
}
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(rest, "provider");
if (provider === "gitee" || provider === "github") attrs.provider = provider;
if (/(^|\s)fullname(\s|$|=)/i.test(rest)) {
const v = parseAttrValue(rest, "fullname");
attrs.fullname = v ? v !== "false" : true;
}
return { type: "repo-card", markerLen, attrs };
}
if (keyword === "link-card") {
// href is required and supports either `href="..."` or a positional URL
// after the keyword (matches the `repo-card` convention).
let href = parseAttrValue(rest, "href") ?? "";
if (!href) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && !positional.includes("=")) href = positional;
}
if (!href) return null;
const attrs: LinkCardContainerAttrs = { href };
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(rest, "description");
if (description) attrs.description = description;
const target = parseAttrValue(rest, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(rest, "rel");
if (rel) attrs.rel = rel;
return { type: "link-card", markerLen, attrs };
}
if (keyword === "image-card") {
// image is required and supports either `image="..."` or a positional URL
let image = parseAttrValue(rest, "image") ?? "";
if (!image) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && !positional.includes("=")) image = positional;
}
if (!image) return null;
const attrs: ImageCardContainerAttrs = { image };
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const description = parseAttrValue(rest, "description");
if (description) attrs.description = description;
const href = parseAttrValue(rest, "href");
if (href) attrs.href = href;
const author = parseAttrValue(rest, "author");
if (author) attrs.author = author;
const date = parseAttrValue(rest, "date");
if (date) attrs.date = date;
const width = parseAttrValue(rest, "width");
if (width) attrs.width = width;
const center = parseAttrValue(rest, "center");
if (center !== undefined) attrs.center = center !== "false";
else if (/(^|\s)center(\s|$)/.test(rest)) attrs.center = true;
return { type: "image-card", markerLen, attrs };
}
if (keyword === "field") {
const name = parseAttrValue(rest, "name") ?? "";
if (!name) return null;
const attrs: FieldContainerAttrs = { name };
const type = parseAttrValue(rest, "type");
if (type) attrs.type = type;
const def = parseAttrValue(rest, "default");
if (def !== undefined) attrs.default = def;
if (/(^|\s)required(\s|$)/.test(rest)) attrs.required = true;
if (/(^|\s)optional(\s|$)/.test(rest)) attrs.optional = true;
if (/(^|\s)deprecated(\s|$)/.test(rest)) attrs.deprecated = true;
return { type: "field", markerLen, attrs };
}
if (keyword === "field-group") {
return { type: "field-group", markerLen, attrs: {} as FieldGroupContainerAttrs };
}
if (keyword === "flex") {
return { type: "flex", markerLen, attrs: parseFlexContainerAttrs(rest) };
}
if (keyword === "center" || keyword === "right") {
return { type: "align", markerLen, attrs: { align: keyword as AlignContainerType } };
}
if (keyword === "window" || keyword === "demo-wrapper") {
const attrs: WindowContainerAttrs = {};
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const height = parseAttrValue(rest, "height");
if (height) attrs.height = height;
const gap = parseAttrValue(rest, "gap");
if (gap) attrs.gap = gap;
if (/(^|\s)no-?padding(\s|$)/i.test(rest)) attrs.noPadding = true;
return { type: "window", markerLen, attrs };
}
if (keyword === "chat") {
const attrs: ChatContainerAttrs = {};
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
return { type: "chat", markerLen, attrs };
}
if (keyword === "timeline") {
const attrs: TimelineContainerAttrs = {};
if (/(^|\s)horizontal(\s|$|=)/i.test(rest)) {
const horizontalVal = parseAttrValue(rest, "horizontal");
attrs.horizontal = horizontalVal ? horizontalVal !== "false" : true;
}
if (/(^|\s)card(\s|$|=)/i.test(rest)) {
const cardVal = parseAttrValue(rest, "card");
attrs.card = cardVal ? cardVal !== "false" : true;
}
const placement = parseAttrValue(rest, "placement");
if (placement === "left" || placement === "right" || placement === "between") {
attrs.placement = placement as TimelinePlacement;
}
const line = parseAttrValue(rest, "line");
if (line === "solid" || line === "dashed" || line === "dotted") {
attrs.line = line as TimelineLineStyle;
}
return { type: "timeline", markerLen, attrs };
}
if (
keyword === "note" ||
keyword === "info" ||
keyword === "tip" ||
keyword === "warning" ||
keyword === "caution" ||
keyword === "details" ||
keyword === "important"
) {
const title = rest.trim() || undefined;
return {
type: "prompt",
markerLen,
attrs: {
type: keyword,
title
}
};
}
return null;
}
/**
* Scan markdown text and return every top-level block we know how to render.
* Inner / nested blocks are NOT returned here; they are re-discovered when the
* outer block content is rendered recursively.
*
* Top-level rules:
* - `:::` containers respect fenced code blocks (ignore markers inside fences)
* - close marker must have marker-length >= open marker-length and appear at
* matching nesting depth (where depth counts ANY `:::xxx` headers, regardless of keyword)
* - `@[code-tree ...](path)` single-line embeds are detected when they appear
* outside any container or fenced code block.
*/
export function parseAllBlocks(
text: string,
fallbackIcon: FileTreeIconMode = RE_DEFAULT_ICON_FALLBACK
): ParsedBlock[] {
const lines = text.split(/\r?\n/);
const blocks: ParsedBlock[] = [];
let i = 0;
let fenceChar = "";
let fenceLen = 0;
while (i < lines.length) {
const line = lines[i];
// Track fenced code blocks at the top level so we don't mis-parse ::: inside them.
if (fenceLen > 0) {
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLen},}\\s*$`);
if (closeRegex.test(line)) {
fenceChar = "";
fenceLen = 0;
}
i += 1;
continue;
}
const fenceMatch = line.match(RE_FENCE_OPEN);
if (fenceMatch) {
fenceChar = fenceMatch[2][0];
fenceLen = fenceMatch[2].length;
i += 1;
continue;
}
const embedMatch = line.match(CODE_TREE_EMBED_RE_LINE);
if (embedMatch) {
const info = embedMatch[1] ?? "";
const dirPath = (embedMatch[2] ?? "").trim();
if (dirPath) {
const attrs = parseCodeTreeContainerHeader(`::: code-tree${info}`, fallbackIcon);
if (attrs) {
blocks.push({
type: "code-tree-embed",
startLine: i,
endLine: i,
rawContent: "",
markerLen: 0,
attrs: { ...attrs, dirPath } as CodeTreeContainerAttrs & { dirPath: string }
});
}
}
i += 1;
continue;
}
const header = detectContainerOpen(line, fallbackIcon);
if (!header) {
const htmlComponents = [
{ tag: "CardGrid", type: "card-grid", attrs: parseCardGridAttrs },
{ tag: "CardMasonry", type: "card-masonry", attrs: parseCardMasonryAttrs },
{ tag: "Card", type: "card", attrs: parseCardAttrs },
{ tag: "RepoCard", type: "repo-card", attrs: parseRepoCardAttrs },
{ tag: "LinkCard", type: "link-card", attrs: parseLinkCardAttrs },
{ tag: "ImageCard", type: "image-card", attrs: parseImageCardAttrs }
] as const;
let matchedHtmlComponent = false;
for (const component of htmlComponents) {
const open = matchHtmlComponentOpen(line, component.tag);
if (!open) {
continue;
}
const attrs = component.attrs(open.attrs);
if (!attrs) {
continue;
}
const collected = collectHtmlComponentBlock(lines, i, component.tag, open);
if (!collected) {
continue;
}
blocks.push({
type: component.type,
startLine: i,
endLine: collected.endLine,
rawContent: collected.rawContent,
markerLen: 0,
attrs
} as ParsedBlock);
i = collected.endLine + 1;
matchedHtmlComponent = true;
break;
}
if (matchedHtmlComponent) {
continue;
}
i += 1;
continue;
}
// Scan forward to the matching close marker, tracking nested containers
// and inner fences. We DO NOT validate inner content here; the dedicated
// renderer will re-parse it.
let innerFenceChar = "";
let innerFenceLen = 0;
let nestedDepth = 0;
let closeLine = -1;
const buf: string[] = [];
for (let j = i + 1; j < lines.length; j += 1) {
const cur = lines[j];
if (innerFenceLen > 0) {
buf.push(cur);
const closeRegex = new RegExp(`^\\s*${innerFenceChar}{${innerFenceLen},}\\s*$`);
if (closeRegex.test(cur)) {
innerFenceChar = "";
innerFenceLen = 0;
}
continue;
}
const innerFenceMatch = cur.match(RE_FENCE_OPEN);
if (innerFenceMatch) {
innerFenceChar = innerFenceMatch[2][0];
innerFenceLen = innerFenceMatch[2].length;
buf.push(cur);
continue;
}
const closeMatch = cur.match(/^\s*(:{3,})\s*$/);
if (closeMatch) {
const ml = closeMatch[1].length;
if (ml >= header.markerLen && nestedDepth === 0) {
closeLine = j;
break;
}
if (nestedDepth > 0) {
nestedDepth -= 1;
}
buf.push(cur);
continue;
}
if (/^\s*:{3,}\s*[a-zA-Z]/.test(cur)) {
nestedDepth += 1;
}
buf.push(cur);
}
if (closeLine === -1) {
// Unterminated container �� skip the open marker and continue scanning.
i += 1;
continue;
}
blocks.push({
type: header.type,
startLine: i,
endLine: closeLine,
rawContent: buf.join("\n"),
markerLen: header.markerLen,
attrs: header.attrs
});
i = closeLine + 1;
}
return blocks;
}
# Parser fixture
:::: card-grid cols="2"
::: card title="Backend" icon="server"
::: collapse expand
- API
::: code-tabs
@tab GET
```ts
export const method = "GET"@tab POST
export const method = "POST":::
Worker
Queue details.
:::
:::
::: card title="Frontend" icon="layout"
Client details.
:::
::::
/* AUTO-GENERATED FILE. DO NOT EDIT. */
/* Generated by scripts/generate-vuepress-file-icons.mjs */
export interface VuepressFileIconRules {
named: Record<string, string>;
folders: Record<string, string>;
files: Record<string, string>;
extensions: Record<string, string>;
partials: Record<string, string>;
}
export const VUEPRESS_FILE_ICON_RULES: VuepressFileIconRules = {
named: {
"pnpm": "vscode-icons:file-type-light-pnpm",
"npm": "logos:npm-icon",
"yarn": "vscode-icons:file-type-yarn",
"bun": "vscode-icons:file-type-bun",
"deno": "vscode-icons:file-type-light-deno",
"rollup": "vscode-icons:file-type-rollup",
"webpack": "vscode-icons:file-type-webpack",
"vite": "vscode-icons:file-type-vite",
"esbuild": "vscode-icons:file-type-esbuild",
"vue": "vscode-icons:file-type-vue",
"svelte": "vscode-icons:file-type-svelte",
"sveltekit": "vscode-icons:file-type-svelte",
"angular": "vscode-icons:file-type-angular",
"react": "vscode-icons:file-type-reactjs",
"next": "vscode-icons:file-type-light-next",
"nextjs": "vscode-icons:file-type-light-next",
"nuxt": "vscode-icons:file-type-nuxt",
"nuxtjs": "vscode-icons:file-type-nuxt",
"solid": "logos:solidjs-icon",
"solidjs": "logos:solidjs-icon",
"astro": "vscode-icons:file-type-light-astro",
"vitest": "vscode-icons:file-type-vitest",
"playwright": "vscode-icons:file-type-playwright",
"jest": "vscode-icons:file-type-jest",
"cypress": "vscode-icons:file-type-cypress",
"docker": "vscode-icons:file-type-docker",
"html": "vscode-icons:file-type-html",
"javascript": "vscode-icons:file-type-js-official",
"js": "vscode-icons:file-type-js-official",
"typescript": "vscode-icons:file-type-typescript-official",
"ts": "vscode-icons:file-type-typescript-official",
"css": "vscode-icons:file-type-css",
"less": "vscode-icons:file-type-less",
"scss": "vscode-icons:file-type-scss",
"sass": "vscode-icons:file-type-sass",
"stylus": "vscode-icons:file-type-light-stylus",
"postcss": "vscode-icons:file-type-postcss",
"sh": "vscode-icons:file-type-shell",
"shell": "vscode-icons:file-type-shell",
"bash": "vscode-icons:file-type-shell",
"java": "vscode-icons:file-type-java",
"php": "vscode-icons:file-type-php3",
"c": "vscode-icons:file-type-c",
"python": "vscode-icons:file-type-python",
"kotlin": "vscode-icons:file-type-kotlin",
"go": "vscode-icons:file-type-go-gopher",
"golang": "vscode-icons:file-type-go-gopher",
"rust": "vscode-icons:file-type-rust",
"zig": "vscode-icons:file-type-zig",
"swift": "vscode-icons:file-type-swift",
"c#": "vscode-icons:file-type-csharp",
"csharp": "vscode-icons:file-type-csharp",
"c++": "vscode-icons:file-type-cpp",
"ruby": "vscode-icons:file-type-ruby",
"makefile": "vscode-icons:file-type-makefile",
"object-c": "vscode-icons:file-type-objectivec",
"sql": "vscode-icons:file-type-sql",
"mysql": "vscode-icons:file-type-mysql",
"pgsql": "vscode-icons:file-type-pgsql",
"postgresql": "vscode-icons:file-type-pgsql",
"xml": "vscode-icons:file-type-xml",
"wasm": "vscode-icons:file-type-wasm",
"webassembly": "vscode-icons:file-type-wasm",
"toml": "vscode-icons:file-type-light-toml",
"yaml": "vscode-icons:file-type-light-yaml",
},
folders: {
"default": "vscode-icons:default-folder",
"src": "vscode-icons:folder-type-src",
"srcs": "vscode-icons:folder-type-src",
"source": "vscode-icons:folder-type-src",
"sources": "vscode-icons:folder-type-src",
"code": "vscode-icons:folder-type-src",
"tauri-src": "vscode-icons:folder-type-tauri",
"dist": "vscode-icons:folder-type-dist",
"out": "vscode-icons:folder-type-dist",
"output": "vscode-icons:folder-type-dist",
"release": "vscode-icons:folder-type-dist",
"bin": "vscode-icons:folder-type-dist",
"distribution": "vscode-icons:folder-type-dist",
"docs": "vscode-icons:folder-type-docs",
"doc": "vscode-icons:folder-type-docs",
"document": "vscode-icons:folder-type-docs",
"documents": "vscode-icons:folder-type-docs",
"documentation": "vscode-icons:folder-type-docs",
"post": "vscode-icons:folder-type-docs",
"posts": "vscode-icons:folder-type-docs",
"article": "vscode-icons:folder-type-docs",
"articles": "vscode-icons:folder-type-docs",
"scripts": "vscode-icons:folder-type-script",
"script": "vscode-icons:folder-type-script",
"node_modules": "vscode-icons:folder-type-light-node",
"cli": "vscode-icons:folder-type-cli",
"template": "vscode-icons:folder-type-template",
"templates": "vscode-icons:folder-type-template",
"theme": "vscode-icons:folder-type-theme",
"themes": "vscode-icons:folder-type-theme",
"color": "vscode-icons:folder-type-theme",
"colors": "vscode-icons:folder-type-theme",
"design": "vscode-icons:folder-type-theme",
"designs": "vscode-icons:folder-type-theme",
"packages": "vscode-icons:folder-type-package",
"package": "vscode-icons:folder-type-package",
"pkg": "vscode-icons:folder-type-package",
"pkgs": "vscode-icons:folder-type-package",
"shared": "vscode-icons:folder-type-shared",
"utils": "vscode-icons:folder-type-tools",
"util": "vscode-icons:folder-type-tools",
"utility": "vscode-icons:folder-type-tools",
"utilities": "vscode-icons:folder-type-tools",
"helper": "vscode-icons:folder-type-helper",
"helpers": "vscode-icons:folder-type-helper",
"tools": "vscode-icons:folder-type-tools",
"toolkit": "vscode-icons:folder-type-tools",
"toolkits": "vscode-icons:folder-type-tools",
"tooling": "vscode-icons:folder-type-tools",
"devtools": "vscode-icons:folder-type-tools",
"component": "vscode-icons:folder-type-component",
"components": "vscode-icons:folder-type-component",
"widget": "vscode-icons:folder-type-component",
"widgets": "vscode-icons:folder-type-component",
"fragments": "vscode-icons:folder-type-component",
"hooks": "vscode-icons:folder-type-hook",
"composables": "vscode-icons:folder-type-hook",
"public": "vscode-icons:folder-type-public",
"www": "vscode-icons:folder-type-public",
"web": "vscode-icons:folder-type-public",
"wwwroot": "vscode-icons:folder-type-public",
"website": "vscode-icons:folder-type-public",
"site": "vscode-icons:folder-type-public",
"browser": "vscode-icons:folder-type-public",
"browsers": "vscode-icons:folder-type-public",
"fonts": "vscode-icons:folder-type-fonts",
"font": "vscode-icons:folder-type-fonts",
"images": "vscode-icons:folder-type-images",
"image": "vscode-icons:folder-type-images",
"imgs": "vscode-icons:folder-type-images",
"img": "vscode-icons:folder-type-images",
"icon": "vscode-icons:folder-type-images",
"icons": "vscode-icons:folder-type-images",
"ico": "vscode-icons:folder-type-images",
"icos": "vscode-icons:folder-type-images",
"figure": "vscode-icons:folder-type-images",
"figures": "vscode-icons:folder-type-images",
"fig": "vscode-icons:folder-type-images",
"figs": "vscode-icons:folder-type-images",
"screenshot": "vscode-icons:folder-type-images",
"screenshots": "vscode-icons:folder-type-images",
"screengrab": "vscode-icons:folder-type-images",
"screengrabs": "vscode-icons:folder-type-images",
"pic": "vscode-icons:folder-type-images",
"pics": "vscode-icons:folder-type-images",
"picture": "vscode-icons:folder-type-images",
"pictures": "vscode-icons:folder-type-images",
"photo": "vscode-icons:folder-type-images",
"photos": "vscode-icons:folder-type-images",
"photograph": "vscode-icons:folder-type-images",
"photographs": "vscode-icons:folder-type-images",
"asset": "vscode-icons:folder-type-asset",
"assets": "vscode-icons:folder-type-asset",
"resource": "vscode-icons:folder-type-asset",
"resources": "vscode-icons:folder-type-asset",
"res": "vscode-icons:folder-type-asset",
"static": "vscode-icons:folder-type-asset",
"report": "vscode-icons:folder-type-asset",
"reports": "vscode-icons:folder-type-asset",
"apis": "vscode-icons:folder-type-api",
"api": "vscode-icons:folder-type-api",
"restapi": "vscode-icons:folder-type-api",
"style": "vscode-icons:folder-type-style",
"styles": "vscode-icons:folder-type-style",
"stylesheet": "vscode-icons:folder-type-style",
"stylesheets": "vscode-icons:folder-type-style",
"css": "vscode-icons:folder-type-css",
"scss": "vscode-icons:folder-type-light-sass",
"sass": "vscode-icons:folder-type-light-sass",
"less": "vscode-icons:folder-type-less",
"plugin": "vscode-icons:folder-type-plugin",
"plugins": "vscode-icons:folder-type-plugin",
"typings": "vscode-icons:folder-type-typings",
"types": "vscode-icons:folder-type-typings",
"mock": "vscode-icons:folder-type-mock",
"i18n": "vscode-icons:folder-type-locale",
"locales": "vscode-icons:folder-type-locale",
"locale": "vscode-icons:folder-type-locale",
"lang": "vscode-icons:folder-type-locale",
"langs": "vscode-icons:folder-type-locale",
"language": "vscode-icons:folder-type-locale",
"languages": "vscode-icons:folder-type-locale",
"l10n": "vscode-icons:folder-type-locale",
"localization": "vscode-icons:folder-type-locale",
"translation": "vscode-icons:folder-type-locale",
"translate": "vscode-icons:folder-type-locale",
"translations": "vscode-icons:folder-type-locale",
"tx": "vscode-icons:folder-type-locale",
"config": "vscode-icons:folder-type-config",
"configs": "vscode-icons:folder-type-config",
".config": "vscode-icons:folder-type-config",
".configs": "vscode-icons:folder-type-config",
"cfg": "vscode-icons:folder-type-config",
"cfgs": "vscode-icons:folder-type-config",
"conf": "vscode-icons:folder-type-config",
"confs": "vscode-icons:folder-type-config",
"configuration": "vscode-icons:folder-type-config",
"configurations": "vscode-icons:folder-type-config",
"setting": "vscode-icons:folder-type-config",
"settings": "vscode-icons:folder-type-config",
"option": "vscode-icons:folder-type-config",
"options": "vscode-icons:folder-type-config",
"controller": "vscode-icons:folder-type-controller",
"controllers": "vscode-icons:folder-type-controller",
"model": "vscode-icons:folder-type-model",
"models": "vscode-icons:folder-type-model",
"service": "vscode-icons:folder-type-services",
"services": "vscode-icons:folder-type-services",
"view": "vscode-icons:folder-type-view",
"views": "vscode-icons:folder-type-view",
"page": "vscode-icons:folder-type-view",
"pages": "vscode-icons:folder-type-view",
"html": "vscode-icons:folder-type-view",
"app": "vscode-icons:folder-type-app",
"apps": "vscode-icons:folder-type-app",
"client": "vscode-icons:folder-type-client",
"clients": "vscode-icons:folder-type-client",
"frontend": "vscode-icons:folder-type-client",
"frontends": "vscode-icons:folder-type-client",
"server": "vscode-icons:folder-type-server",
"servers": "vscode-icons:folder-type-server",
"backend": "vscode-icons:folder-type-server",
"backends": "vscode-icons:folder-type-server",
"db": "vscode-icons:folder-type-db",
"database": "vscode-icons:folder-type-db",
"databases": "vscode-icons:folder-type-db",
"data": "vscode-icons:folder-type-db",
"sql": "vscode-icons:folder-type-db",
"e2e": "vscode-icons:folder-type-e2e",
"cypress": "vscode-icons:folder-type-light-cypress",
"test": "vscode-icons:folder-type-test",
"tests": "vscode-icons:folder-type-test",
"testing": "vscode-icons:folder-type-test",
"snapshots": "vscode-icons:folder-type-test",
"spec": "vscode-icons:folder-type-test",
"specs": "vscode-icons:folder-type-test",
"lib": "vscode-icons:folder-type-library",
"libs": "vscode-icons:folder-type-library",
"library": "vscode-icons:folder-type-library",
"libraries": "vscode-icons:folder-type-library",
"vendor": "vscode-icons:folder-type-library",
"vendors": "vscode-icons:folder-type-library",
"third-party": "vscode-icons:folder-type-library",
"lib64": "vscode-icons:folder-type-library",
"include": "vscode-icons:folder-type-include",
"inc": "vscode-icons:folder-type-include",
"includes": "vscode-icons:folder-type-include",
"partial": "vscode-icons:folder-type-include",
"partials": "vscode-icons:folder-type-include",
"inc64": "vscode-icons:folder-type-include",
"temp": "vscode-icons:folder-type-temp",
"tmp": "vscode-icons:folder-type-temp",
"cache": "vscode-icons:folder-type-temp",
"cached": "vscode-icons:folder-type-temp",
".temp": "vscode-icons:folder-type-temp",
".cache": "vscode-icons:folder-type-temp",
"log": "vscode-icons:folder-type-log",
"logs": "vscode-icons:folder-type-log",
"logging": "vscode-icons:folder-type-log",
".svelte-kit": "vscode-icons:folder-type-svelte",
".git": "vscode-icons:folder-type-git",
".github": "vscode-icons:folder-type-github",
".gitlab": "vscode-icons:folder-type-gitlab",
".vscode": "vscode-icons:folder-type-vscode",
".husky": "vscode-icons:folder-type-husky",
".idea": "vscode-icons:folder-type-idea",
".changesets": "vscode-icons:folder-type-changesets",
".vercel": "vscode-icons:folder-type-vercel",
".netlify": "vscode-icons:folder-type-netlify",
".claude": "vscode-icons:folder-type-claude",
".cursor": "vscode-icons:folder-type-cursor",
".gemini": "vscode-icons:folder-type-gemini",
".windsurf": "vscode-icons:folder-type-windsurf",
},
files: {
"package.json": "vscode-icons:file-type-node",
"pnpm-debug.log": "vscode-icons:file-type-light-pnpm",
"pnpm-lock.yaml": "vscode-icons:file-type-light-pnpm",
"pnpm-workspace.yaml": "vscode-icons:file-type-light-pnpm",
".pnpmfile.cjs": "vscode-icons:file-type-light-pnpm",
"pnpmfile.js": "vscode-icons:file-type-light-pnpm",
"biome.json": "vscode-icons:file-type-biome",
"bun.lockb": "vscode-icons:file-type-bun",
"commit_editmsg": "vscode-icons:file-type-git",
"merge_msg": "vscode-icons:file-type-git",
"karma.conf.js": "vscode-icons:file-type-karma",
"karma.conf.cjs": "vscode-icons:file-type-karma",
"karma.conf.mjs": "vscode-icons:file-type-karma",
"karma.conf.coffee": "vscode-icons:file-type-karma",
"readme.md": "flat-color-icons:info",
"readme.txt": "flat-color-icons:info",
"readme": "flat-color-icons:info",
"changelog.md": "catppuccin:changelog",
"changelog.txt": "catppuccin:changelog",
"changelog": "catppuccin:changelog",
"changes.md": "catppuccin:changelog",
"changes.txt": "catppuccin:changelog",
"changes": "catppuccin:changelog",
"version.md": "catppuccin:changelog",
"version.txt": "catppuccin:changelog",
"version": "catppuccin:changelog",
"mvnw": "vscode-icons:file-type-maven",
"pom.xml": "vscode-icons:file-type-maven",
"tsconfig.json": "vscode-icons:file-type-tsconfig",
"swagger.json": "vscode-icons:file-type-swagger",
"swagger.yml": "vscode-icons:file-type-swagger",
"swagger.yaml": "vscode-icons:file-type-swagger",
"mime.types": "vscode-icons:file-type-light-config",
"jenkinsfile": "vscode-icons:file-type-jenkins",
"babel.config.js": "vscode-icons:file-type-babel2",
"babel.config.json": "vscode-icons:file-type-babel2",
"babel.config.cjs": "vscode-icons:file-type-babel2",
"build": "vscode-icons:file-type-bazel",
"build.bazel": "vscode-icons:file-type-bazel",
"workspace": "vscode-icons:file-type-bazel",
"workspace.bazel": "vscode-icons:file-type-bazel",
"bower.json": "vscode-icons:file-type-bower2",
"eslint.config.js": "vscode-icons:file-type-eslint",
"eslint.config.ts": "vscode-icons:file-type-eslint",
"firebase.json": "vscode-icons:file-type-firebase",
"geckodriver": "openmoji:firefox",
"gruntfile.js": "vscode-icons:file-type-grunt",
"gruntfile.babel.js": "vscode-icons:file-type-grunt",
"gruntfile.coffee": "vscode-icons:file-type-grunt",
"ionic.config.json": "vscode-icons:file-type-ionic",
"ionic.project": "vscode-icons:file-type-ionic",
"platformio.ini": "vscode-icons:file-type-platformio",
"rollup.config.js": "vscode-icons:file-type-rollup",
"sass-lint.yml": "vscode-icons:file-type-sass",
"stylelint.config.js": "vscode-icons:file-type-light-stylelint",
"stylelint.config.cjs": "vscode-icons:file-type-light-stylelint",
"stylelint.config.mjs": "vscode-icons:file-type-light-stylelint",
"yarn.clean": "vscode-icons:file-type-yarn",
"yarn.lock": "vscode-icons:file-type-yarn",
"webpack.config.js": "vscode-icons:file-type-webpack",
"webpack.config.cjs": "vscode-icons:file-type-webpack",
"webpack.config.mjs": "vscode-icons:file-type-webpack",
"webpack.config.ts": "vscode-icons:file-type-webpack",
"webpack.config.build.js": "vscode-icons:file-type-webpack",
"webpack.config.build.cjs": "vscode-icons:file-type-webpack",
"webpack.config.build.mjs": "vscode-icons:file-type-webpack",
"webpack.config.build.ts": "vscode-icons:file-type-webpack",
"webpack.common.js": "vscode-icons:file-type-webpack",
"webpack.common.cjs": "vscode-icons:file-type-webpack",
"webpack.common.mjs": "vscode-icons:file-type-webpack",
"webpack.common.ts": "vscode-icons:file-type-webpack",
"webpack.dev.js": "vscode-icons:file-type-webpack",
"webpack.dev.cjs": "vscode-icons:file-type-webpack",
"webpack.dev.mjs": "vscode-icons:file-type-webpack",
"webpack.dev.ts": "vscode-icons:file-type-webpack",
"webpack.prod.js": "vscode-icons:file-type-webpack",
"webpack.prod.cjs": "vscode-icons:file-type-webpack",
"webpack.prod.mjs": "vscode-icons:file-type-webpack",
"webpack.prod.ts": "vscode-icons:file-type-webpack",
"vite.config.js": "vscode-icons:file-type-vite",
"vite.config.ts": "vscode-icons:file-type-vite",
"vite.config.mjs": "vscode-icons:file-type-vite",
"vite.config.cjs": "vscode-icons:file-type-vite",
"vitest.config.ts": "vscode-icons:file-type-vitest",
"vitest.config.js": "vscode-icons:file-type-vitest",
"vitest.config.mjs": "vscode-icons:file-type-vitest",
"vitest.config.cjs": "vscode-icons:file-type-vitest",
"turbo.json": "vscode-icons:file-type-light-turbo",
"vercel.json": "vscode-icons:file-type-light-vercel",
"netlify.toml": "vscode-icons:file-type-light-netlify",
"cargo.toml": "vscode-icons:file-type-cargo",
"cargo.lock": "vscode-icons:file-type-cargo",
"npm-debug.log": "vscode-icons:file-type-npm",
"components.json": "vscode-icons:file-type-light-shadcn",
".postcssrc": "vscode-icons:file-type-postcssconfig",
".postcssrc.json": "vscode-icons:file-type-postcssconfig",
".postcssrc.yaml": "vscode-icons:file-type-postcssconfig",
".postcssrc.yml": "vscode-icons:file-type-postcssconfig",
".postcssrc.ts": "vscode-icons:file-type-postcssconfig",
".postcssrc.cts": "vscode-icons:file-type-postcssconfig",
".postcssrc.mts": "vscode-icons:file-type-postcssconfig",
".postcssrc.js": "vscode-icons:file-type-postcssconfig",
".postcssrc.cjs": "vscode-icons:file-type-postcssconfig",
".postcssrc.mjs": "vscode-icons:file-type-postcssconfig",
"postcss.config.ts": "vscode-icons:file-type-postcssconfig",
"postcss.config.cts": "vscode-icons:file-type-postcssconfig",
"postcss.config.mts": "vscode-icons:file-type-postcssconfig",
"postcss.config.js": "vscode-icons:file-type-postcssconfig",
"postcss.config.cjs": "vscode-icons:file-type-postcssconfig",
"postcss.config.mjs": "vscode-icons:file-type-postcssconfig",
"uno.config.js": "vscode-icons:file-type-unocss",
"uno.config.mjs": "vscode-icons:file-type-unocss",
"uno.config.ts": "vscode-icons:file-type-unocss",
"unocss.config.js": "vscode-icons:file-type-unocss",
"unocss.config.mjs": "vscode-icons:file-type-unocss",
"unocss.config.ts": "vscode-icons:file-type-unocss",
"rolldown.config.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.common.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.dev.ts": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.js": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.cjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.mjs": "vscode-icons:file-type-light-rolldown",
"rolldown.config.prod.ts": "vscode-icons:file-type-light-rolldown",
"tsdown.config.js": "vscode-icons:file-type-tsdown",
"tsdown.config.cjs": "vscode-icons:file-type-tsdown",
"tsdown.config.mjs": "vscode-icons:file-type-tsdown",
"tsdown.config.ts": "vscode-icons:file-type-tsdown",
"tsdown.config.json": "vscode-icons:file-type-tsdown",
".oxlintignore": "vscode-icons:file-type-oxc",
".oxlintrc.json": "vscode-icons:file-type-oxc",
".oxlintrc.jsonc": "vscode-icons:file-type-oxc",
".oxfmtrc.json": "vscode-icons:file-type-oxc",
".oxfmtrc.jsonc": "vscode-icons:file-type-oxc",
"agents.md": "vscode-icons:file-type-agents",
"claude.md": "vscode-icons:file-type-claude",
"copilot-instructions.md": "vscode-icons:file-type-copilot",
"github-copilot.xml": "vscode-icons:file-type-copilot",
"instructions.md": "vscode-icons:file-type-copilot",
},
extensions: {
".astro": "vscode-icons:file-type-light-astro",
".mdx": "vscode-icons:file-type-light-mdx",
".cls": "vscode-icons:file-type-apex",
".apex": "vscode-icons:file-type-apex",
".asm": "vscode-icons:file-type-assembly",
".s": "vscode-icons:file-type-assembly",
".bicep": "vscode-icons:file-type-bicep",
".bzl": "vscode-icons:file-type-bazel",
".bazel": "vscode-icons:file-type-bazel",
".build": "vscode-icons:file-type-bazel",
".workspace": "vscode-icons:file-type-bazel",
".bazelignore": "vscode-icons:file-type-bazel",
".bazelversion": "vscode-icons:file-type-bazel",
".c": "vscode-icons:file-type-c",
".h": "vscode-icons:file-type-c",
".m": "vscode-icons:file-type-c",
".cs": "vscode-icons:file-type-csharp",
".cshtml": "vscode-icons:file-type-html",
".aspx": "vscode-icons:file-type-aspx",
".ascx": "vscode-icons:file-type-aspx",
".asax": "vscode-icons:file-type-aspx",
".master": "vscode-icons:file-type-html",
".cc": "vscode-icons:file-type-cpp",
".cpp": "vscode-icons:file-type-cpp",
".cxx": "vscode-icons:file-type-cpp",
".c++": "vscode-icons:file-type-cpp",
".hh": "vscode-icons:file-type-cppheader",
".hpp": "vscode-icons:file-type-cppheader",
".hxx": "vscode-icons:file-type-cppheader",
".h++": "vscode-icons:file-type-cppheader",
".mm": "vscode-icons:file-type-cpp",
".clj": "vscode-icons:file-type-clojure",
".cljs": "vscode-icons:file-type-clojure",
".cljc": "vscode-icons:file-type-clojure",
".edn": "vscode-icons:file-type-clojure",
".cfc": "vscode-icons:file-type-cfc",
".cfm": "vscode-icons:file-type-cfm",
".coffee": "vscode-icons:file-type-coffeescript",
".litcoffee": "vscode-icons:file-type-coffeescript",
".config": "vscode-icons:file-type-config",
".cfg": "vscode-icons:file-type-config",
".conf": "vscode-icons:file-type-config",
".cr": "vscode-icons:file-type-light-crystal",
".dll": "vscode-icons:file-type-binary",
".ecr": "vscode-icons:file-type-light-crystal",
".slang": "vscode-icons:file-type-slang",
".cson": "vscode-icons:file-type-json",
".css": "vscode-icons:file-type-css",
".css.map": "vscode-icons:file-type-cssmap",
".sss": "vscode-icons:file-type-sss",
".csv": "vscode-icons:file-type-excel",
".xls": "vscode-icons:file-type-excel2",
".xlsx": "vscode-icons:file-type-excel2",
".cu": "vscode-icons:file-type-cuda",
".cuh": "vscode-icons:file-type-cuda",
".hu": "vscode-icons:file-type-cuda",
".cake": "vscode-icons:file-type-cake",
".ctp": "vscode-icons:file-type-cakephp",
".d": "vscode-icons:file-type-dependencies",
".doc": "vscode-icons:file-type-word2",
".docx": "vscode-icons:file-type-word2",
".ejs": "vscode-icons:file-type-ejs",
".ex": "vscode-icons:file-type-elixir",
".exs": "vscode-icons:file-type-elixir",
".elm": "vscode-icons:file-type-elm",
".ico": "vscode-icons:file-type-favicon",
".fs": "vscode-icons:file-type-fsharp",
".fsx": "vscode-icons:file-type-fsharp",
".gitignore": "vscode-icons:file-type-git",
".gitconfig": "vscode-icons:file-type-git",
".gitkeep": "vscode-icons:file-type-git",
".gitattributes": "vscode-icons:file-type-git",
".gitmodules": "vscode-icons:file-type-git",
".go": "vscode-icons:file-type-go",
".slide": "vscode-icons:file-type-go",
".article": "vscode-icons:file-type-go",
".gd": "vscode-icons:file-type-godot",
".godot": "vscode-icons:file-type-godot",
".tres": "vscode-icons:file-type-godot",
".tscn": "vscode-icons:file-type-godot",
".gradle": "vscode-icons:file-type-light-gradle",
".groovy": "vscode-icons:file-type-groovy",
".gsp": "vscode-icons:file-type-groovy",
".gql": "vscode-icons:file-type-graphql",
".graphql": "vscode-icons:file-type-graphql",
".graphqls": "vscode-icons:file-type-graphql",
".hack": "logos:hack",
".haml": "vscode-icons:file-type-haml",
".handlebars": "vscode-icons:file-type-handlebars",
".hbs": "vscode-icons:file-type-handlebars",
".hjs": "vscode-icons:file-type-handlebars",
".hs": "vscode-icons:file-type-haskell",
".lhs": "vscode-icons:file-type-haskell",
".hx": "vscode-icons:file-type-haxe",
".hxs": "vscode-icons:file-type-haxe",
".hxp": "vscode-icons:file-type-haxe",
".hxml": "vscode-icons:file-type-haxe",
".html": "vscode-icons:file-type-html",
".jade": "file-icons:jade",
".java": "vscode-icons:file-type-java",
".class": "vscode-icons:file-type-java",
".classpath": "vscode-icons:file-type-java",
".properties": "vscode-icons:file-type-java",
".js": "vscode-icons:file-type-js",
".js.map": "vscode-icons:file-type-jsmap",
".cjs": "vscode-icons:file-type-js",
".cjs.map": "vscode-icons:file-type-jsmap",
".mjs": "vscode-icons:file-type-js",
".mjs.map": "vscode-icons:file-type-jsmap",
".spec.js": "vscode-icons:file-type-light-testjs",
".spec.cjs": "vscode-icons:file-type-light-testjs",
".spec.mjs": "vscode-icons:file-type-light-testjs",
".test.js": "vscode-icons:file-type-light-testjs",
".test.cjs": "vscode-icons:file-type-light-testjs",
".test.mjs": "vscode-icons:file-type-light-testjs",
".es": "vscode-icons:file-type-js",
".es5": "vscode-icons:file-type-js",
".es6": "vscode-icons:file-type-js",
".es7": "vscode-icons:file-type-js",
".jinja": "vscode-icons:file-type-jinja",
".jinja2": "vscode-icons:file-type-jinja",
".json": "vscode-icons:file-type-json",
".jl": "vscode-icons:file-type-julia",
".kt": "vscode-icons:file-type-kotlin",
".kts": "vscode-icons:file-type-kotlin",
".dart": "vscode-icons:file-type-dartlang",
".less": "vscode-icons:file-type-less",
".liquid": "vscode-icons:file-type-liquid",
".ls": "vscode-icons:file-type-livescript",
".lua": "vscode-icons:file-type-lua",
".markdown": "vscode-icons:file-type-markdown",
".md": "vscode-icons:file-type-markdown",
".mustache": "vscode-icons:file-type-light-mustache",
".stache": "vscode-icons:file-type-light-mustache",
".nim": "vscode-icons:file-type-light-nim",
".nims": "vscode-icons:file-type-light-nim",
".github-issues": "mdi:github",
".ipynb": "vscode-icons:file-type-jupyter",
".njk": "vscode-icons:file-type-nunjucks",
".nunjucks": "vscode-icons:file-type-nunjucks",
".nunjs": "vscode-icons:file-type-nunjucks",
".nunj": "vscode-icons:file-type-nunjucks",
".njs": "vscode-icons:file-type-nunjucks",
".nj": "vscode-icons:file-type-nunjucks",
".npm-debug.log": "vscode-icons:file-type-npm",
".npmignore": "catppuccin:npm-ignore",
".npmrc": "vscode-icons:file-type-npm",
".ml": "vscode-icons:file-type-ocaml",
".mli": "vscode-icons:file-type-ocaml",
".cmx": "vscode-icons:file-type-ocaml",
".cmxa": "vscode-icons:file-type-ocaml",
".pl": "vscode-icons:file-type-perl",
".php": "vscode-icons:file-type-php",
".php.inc": "vscode-icons:file-type-php",
".pipeline": "vscode-icons:file-type-pipeline",
".pddl": "vscode-icons:file-type-pddl",
".plan": "vscode-icons:file-type-pddl-plan",
".happenings": "vscode-icons:file-type-pddl-happenings",
".ps1": "vscode-icons:file-type-powershell",
".psd1": "vscode-icons:file-type-powershell",
".psm1": "vscode-icons:file-type-powershell",
".prisma": "vscode-icons:file-type-light-prisma",
".pug": "vscode-icons:file-type-pug",
".pp": "vscode-icons:file-type-puppet",
".epp": "vscode-icons:file-type-puppet",
".purs": "vscode-icons:file-type-light-purescript",
".py": "vscode-icons:file-type-python",
".jsx": "vscode-icons:file-type-reactjs",
".spec.jsx": "vscode-icons:file-type-reactjs",
".test.jsx": "vscode-icons:file-type-reactjs",
".cjsx": "vscode-icons:file-type-reactjs",
".tsx": "vscode-icons:file-type-reactts",
".spec.tsx": "vscode-icons:file-type-reactts",
".test.tsx": "vscode-icons:file-type-reactts",
".res": "vscode-icons:file-type-rescript",
".resi": "vscode-icons:file-type-rescript",
".r": "vscode-icons:file-type-r",
".rmd": "vscode-icons:file-type-rmd",
".rb": "vscode-icons:file-type-ruby",
".erb": "vscode-icons:file-type-html",
".erb.html": "vscode-icons:file-type-html",
".html.erb": "vscode-icons:file-type-html",
".rs": "vscode-icons:file-type-rust",
".sass": "vscode-icons:file-type-sass",
".scss": "vscode-icons:file-type-sass",
".springbeans": "mdi:sprout",
".slim": "vscode-icons:file-type-slim",
".smarty.tpl": "vscode-icons:file-type-smarty",
".tpl": "vscode-icons:file-type-smarty",
".sbt": "vscode-icons:file-type-sbt",
".scala": "vscode-icons:file-type-scala",
".sol": "logos:ethereum-color",
".styl": "vscode-icons:file-type-light-stylus",
".svelte": "vscode-icons:file-type-svelte",
".swift": "vscode-icons:file-type-swift",
".sql": "vscode-icons:file-type-sql",
".soql": "vscode-icons:file-type-sql",
".tf": "vscode-icons:file-type-terraform",
".tf.json": "vscode-icons:file-type-terraform",
".tfvars": "vscode-icons:file-type-terraform",
".tfvars.json": "vscode-icons:file-type-terraform",
".tex": "vscode-icons:file-type-light-tex",
".sty": "vscode-icons:file-type-light-tex",
".dtx": "vscode-icons:file-type-light-tex",
".ins": "vscode-icons:file-type-light-tex",
".txt": "vscode-icons:file-type-text",
".toml": "vscode-icons:file-type-light-toml",
".twig": "vscode-icons:file-type-twig",
".ts": "vscode-icons:file-type-typescript",
".spec.ts": "vscode-icons:file-type-testts",
".test.ts": "vscode-icons:file-type-testts",
… (demo build 截断)import { getIconIds, type IconName } from "obsidian";
import { resolveOnlineIconifyId } from "./offlineIconify";
import type { FileTreeIconMode } from "./types";
export interface IconDescriptor {
icon: IconName;
iconifyId?: string;
colorClass?: string;
}
interface IconStyle {
icon?: IconName | IconName[];
colorClass?: string;
}
const DEFAULT_FILE_ICON: IconName = "file";
const DEFAULT_FOLDER_ICON: IconName = "folder";
const DEFAULT_FOLDER_OPEN_ICON: IconName = "folder-open";
const DEFAULT_FILE_COLOR = "ft-color-default";
const DEFAULT_FOLDER_COLOR = "ft-color-folder";
let availableIconSet: Set<string> | null = null;
function ensureIconSet(): Set<string> {
const ids = getIconIds() as string[];
// First call can run before Obsidian finishes registering icons; don't cache an empty set.
if (!availableIconSet || (availableIconSet.size === 0 && ids.length > 0)) {
availableIconSet = new Set(ids);
}
return availableIconSet;
}
/** Pick a known Obsidian icon id from candidates (file-tree helpers). */
export function resolveObsidianIcon(icon: string, fallback: IconName = "file"): IconName {
return safeIcon(icon as IconName, fallback);
}
function safeIcon(icon: IconName | IconName[] | undefined, fallback: IconName): IconName {
if (!icon) {
return fallback;
}
const iconSet = ensureIconSet();
if (Array.isArray(icon)) {
for (const candidate of icon) {
if (iconSet.has(candidate)) {
return candidate;
}
}
return fallback;
}
return iconSet.has(icon) ? icon : fallback;
}
const CANDIDATES = {
package: ["package", "archive", "box"] as IconName[],
lock: ["lock", "shield-lock", "shield"] as IconName[],
docs: ["book-open", "book-text", "file-text"] as IconName[],
security: ["shield", "shield-check", "shield-alert"] as IconName[],
git: ["git-branch", "git-compare", "git-merge"] as IconName[],
env: ["shield", "key-round", "lock"] as IconName[],
config: ["settings", "sliders-horizontal", "wrench"] as IconName[],
codeFile: ["file-code-2", "file-code", "code"] as IconName[],
jsonFile: ["file-json-2", "file-json", "braces"] as IconName[],
markdown: ["book-open", "file-text", "notebook-pen"] as IconName[],
textFile: ["file-text", "file", "notebook-text"] as IconName[],
style: ["palette", "paintbrush-2", "paintbrush"] as IconName[],
image: ["image", "image-up", "file-image"] as IconName[],
video: ["video", "clapperboard", "film"] as IconName[],
music: ["music", "audio-lines", "audio-waveform"] as IconName[],
archive: ["archive", "package", "box"] as IconName[],
database: ["database", "table", "cylinder"] as IconName[],
terminal: ["terminal", "square-terminal", "command"] as IconName[],
srcFolder: ["folder-code", "folder-git-2", "folder"] as IconName[],
docsFolder: ["book-open", "folder", "book-text"] as IconName[],
testFolder: ["flask-conical", "flask-round", "beaker"] as IconName[],
publicFolder: ["globe", "globe-2", "folder"] as IconName[],
assetsFolder: ["image", "folder", "file-image"] as IconName[]
};
const NAMED_FILE_STYLES: Record<string, IconStyle> = {
"package.json": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pnpm-workspace.yaml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pnpm-workspace.yml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"package-lock.json": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"pnpm-lock.yaml": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"yarn.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"bun.lockb": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"readme": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"readme.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"readme.mdx": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"changelog.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"contributing.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"license": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"license.md": { icon: CANDIDATES.docs, colorClass: "ft-color-doc" },
"security.md": { icon: CANDIDATES.security, colorClass: "ft-color-security" },
".gitignore": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".gitattributes": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".gitmodules": { icon: CANDIDATES.git, colorClass: "ft-color-git" },
".env": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.local": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.development": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.production": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
".env.example": { icon: CANDIDATES.env, colorClass: "ft-color-env" },
"dockerfile": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"docker-compose.yml": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"docker-compose.yaml": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
".dockerignore": { icon: CANDIDATES.package, colorClass: "ft-color-docker" },
"tsconfig.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"tsconfig.base.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"jsconfig.json": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vite.config.ts": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vite.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"webpack.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"rollup.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"eslint.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"eslint.config.mjs": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"prettier.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"stylelint.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"vitest.config.ts": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"jest.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"tailwind.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"postcss.config.js": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
"makefile": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
"requirements.txt": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"pyproject.toml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"poetry.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"go.mod": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"go.sum": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" },
"cargo.toml": { icon: CANDIDATES.package, colorClass: "ft-color-package" },
"cargo.lock": { icon: CANDIDATES.lock, colorClass: "ft-color-lock" }
};
const FOLDER_STYLES: Record<string, IconStyle> = {
src: { icon: CANDIDATES.srcFolder, colorClass: "ft-color-src" },
source: { icon: CANDIDATES.srcFolder, colorClass: "ft-color-src" },
docs: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
doc: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
blog: { icon: CANDIDATES.docsFolder, colorClass: "ft-color-docs" },
test: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
tests: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
__tests__: { icon: CANDIDATES.testFolder, colorClass: "ft-color-tests" },
dist: { colorClass: "ft-color-dist" },
build: { colorClass: "ft-color-dist" },
out: { colorClass: "ft-color-dist" },
public: { icon: CANDIDATES.publicFolder, colorClass: "ft-color-public" },
assets: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
images: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
img: { icon: CANDIDATES.assetsFolder, colorClass: "ft-color-assets" },
scripts: { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
script: { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
config: { icon: CANDIDATES.config, colorClass: "ft-color-config" },
node_modules: { colorClass: "ft-color-package" },
types: { colorClass: "ft-color-typescript" },
style: { colorClass: "ft-color-style" },
styles: { colorClass: "ft-color-style" },
database: { icon: CANDIDATES.database, colorClass: "ft-color-database" }
};
const EXTENSION_STYLES: Record<string, IconStyle> = {
".d.ts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".ts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".tsx": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".mts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".cts": { icon: CANDIDATES.codeFile, colorClass: "ft-color-typescript" },
".js": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".jsx": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".mjs": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".cjs": { icon: CANDIDATES.codeFile, colorClass: "ft-color-javascript" },
".vue": { icon: CANDIDATES.codeFile, colorClass: "ft-color-vue" },
".md": { icon: CANDIDATES.markdown, colorClass: "ft-color-markdown" },
".mdx": { icon: CANDIDATES.markdown, colorClass: "ft-color-markdown" },
".txt": { icon: CANDIDATES.textFile, colorClass: "ft-color-doc" },
".pdf": { icon: CANDIDATES.textFile, colorClass: "ft-color-doc" },
".json": { icon: CANDIDATES.jsonFile, colorClass: "ft-color-json" },
".jsonc": { icon: CANDIDATES.jsonFile, colorClass: "ft-color-json" },
".yaml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".yml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".toml": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".ini": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".conf": { icon: CANDIDATES.config, colorClass: "ft-color-config" },
".css": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".scss": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".sass": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".less": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".styl": { icon: CANDIDATES.style, colorClass: "ft-color-style" },
".html": { icon: CANDIDATES.codeFile, colorClass: "ft-color-markup" },
".xml": { icon: CANDIDATES.codeFile, colorClass: "ft-color-markup" },
".svg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".png": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".jpg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".jpeg": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".gif": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".webp": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".avif": { icon: CANDIDATES.image, colorClass: "ft-color-image" },
".mp4": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".mov": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".avi": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".webm": { icon: CANDIDATES.video, colorClass: "ft-color-media" },
".mp3": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".wav": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".ogg": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".m4a": { icon: CANDIDATES.music, colorClass: "ft-color-media" },
".zip": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".rar": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".7z": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".gz": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".tar": { icon: CANDIDATES.archive, colorClass: "ft-color-archive" },
".sql": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".sqlite": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".db": { icon: CANDIDATES.database, colorClass: "ft-color-database" },
".sh": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".bash": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".zsh": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".ps1": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".bat": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" },
".cmd": { icon: CANDIDATES.terminal, colorClass: "ft-color-scripts" }
};
const PARTIAL_STYLES: Array<{ include: string; style: IconStyle }> = [
{ include: "test", style: { colorClass: "ft-color-tests" } },
{ include: "spec", style: { colorClass: "ft-color-tests" } },
{ include: "mock", style: { colorClass: "ft-color-tests" } },
{ include: "config", style: { icon: CANDIDATES.config, colorClass: "ft-color-config" } },
{ include: "docker", style: { icon: CANDIDATES.package, colorClass: "ft-color-docker" } },
{ include: "readme", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "changelog", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "license", style: { icon: CANDIDATES.docs, colorClass: "ft-color-doc" } },
{ include: "security", style: { icon: CANDIDATES.security, colorClass: "ft-color-security" } },
{ include: ".lock", style: { icon: CANDIDATES.lock, colorClass: "ft-color-lock" } }
];
function applyStyle(baseIcon: IconName, defaultColor: string, style: IconStyle | undefined, iconifyId?: string): IconDescriptor {
return {
icon: safeIcon(style?.icon, baseIcon),
iconifyId,
colorClass: style?.colorClass ?? defaultColor
};
}
function pickBaseName(value: string): string {
const normalized = value.replace(/\\/g, "/");
const segment = normalized.split("/").pop();
return segment ?? normalized;
}
function getExtensionCandidates(baseName: string): string[] {
const candidates: string[] = [];
let extension = baseName;
const firstDotIndex = extension.indexOf(".");
if (firstDotIndex === -1) {
return candidates;
}
extension = extension.slice(firstDotIndex);
while (extension !== "") {
candidates.push(extension);
const nextDotIndex = extension.indexOf(".", 1);
if (nextDotIndex === -1) {
break;
}
extension = extension.slice(nextDotIndex);
}
return candidates;
}
export function resolveNodeIcon(
fileName: string,
nodeType: "folder" | "file",
expanded: boolean,
mode: FileTreeIconMode
): IconDescriptor {
const normalizedPath = fileName.replace(/\\/g, "/").toLowerCase();
const baseName = pickBaseName(normalizedPath);
const iconifyId = mode === "colored"
? resolveOnlineIconifyId(normalizedPath, nodeType, expanded)
: undefined;
if (mode === "simple") {
if (nodeType === "folder") {
return { icon: expanded ? DEFAULT_FOLDER_OPEN_ICON : DEFAULT_FOLDER_ICON };
}
return { icon: DEFAULT_FILE_ICON };
}
if (nodeType === "folder") {
const folderStyle = FOLDER_STYLES[baseName];
return applyStyle(
expanded ? DEFAULT_FOLDER_OPEN_ICON : DEFAULT_FOLDER_ICON,
DEFAULT_FOLDER_COLOR,
folderStyle,
iconifyId
);
}
const namedStyle = NAMED_FILE_STYLES[baseName];
if (namedStyle) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, namedStyle, iconifyId);
}
const extensionCandidates = getExtensionCandidates(baseName);
for (const extension of extensionCandidates) {
const extensionStyle = EXTENSION_STYLES[extension];
if (extensionStyle) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, extensionStyle, iconifyId);
}
}
for (const item of PARTIAL_STYLES) {
if (normalizedPath.includes(item.include)) {
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, item.style, iconifyId);
}
}
return applyStyle(DEFAULT_FILE_ICON, DEFAULT_FILE_COLOR, undefined, iconifyId);
}
import {
App,
Component,
MarkdownPostProcessorContext,
MarkdownRenderChild,
MarkdownRenderer
} from "obsidian";
import { applyVuepressMarkdownTransforms } from "../render/markdown-transforms";
import { processIconifyIcons } from "../render/iconify-online";
export interface PlumeMarkdownContext {
app: App;
sourcePath: string;
component: Component;
postProcessorCtx?: MarkdownPostProcessorContext;
}
function createRenderToken(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function attachRenderChild(
host: HTMLElement,
ctx: PlumeMarkdownContext
): MarkdownRenderChild {
const child = new MarkdownRenderChild(host);
if (ctx.postProcessorCtx) {
ctx.postProcessorCtx.addChild(child);
} else {
ctx.component.addChild(child);
}
return child;
}
/**
* Render markdown into `container` using Obsidian's renderer with proper
* lifecycle management (MarkdownRenderChild). Cancels stale async renders via token.
*/
export async function renderPlumeMarkdown(
container: HTMLElement,
markdown: string,
ctx: PlumeMarkdownContext
): Promise<void> {
const source = applyVuepressMarkdownTransforms(markdown);
if (!source.trim()) {
return;
}
const token = createRenderToken();
container.dataset.plumeMdToken = token;
container.empty();
const host = document.createElement("div");
host.classList.add("markdown-rendered");
container.appendChild(host);
const child = attachRenderChild(host, ctx);
try {
await MarkdownRenderer.render(ctx.app, source, host, ctx.sourcePath, child);
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
// Hoist even when `container` is not yet in the live preview tree (nested blocks
// are often built inside a detached staging host before append).
while (host.firstChild) {
container.appendChild(host.firstChild);
}
host.remove();
await processIconifyIcons(container);
} catch {
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
container.empty();
container.textContent = source;
}
}
/**
* Render into a staging host, then move children into `container` (used when
* we must not leave an extra wrapper in the DOM).
*/
export async function renderPlumeMarkdownInto(
container: HTMLElement,
markdown: string,
ctx: PlumeMarkdownContext
): Promise<void> {
const source = applyVuepressMarkdownTransforms(markdown);
if (!source.trim()) {
return;
}
const token = createRenderToken();
container.dataset.plumeMdToken = token;
container.empty();
const host = document.createElement("div");
host.classList.add("markdown-rendered");
container.appendChild(host);
const child = attachRenderChild(host, ctx);
try {
await MarkdownRenderer.render(ctx.app, source, host, ctx.sourcePath, child);
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
while (host.firstChild) {
container.insertBefore(host.firstChild, host);
}
host.remove();
await processIconifyIcons(container);
} catch {
if (container.dataset.plumeMdToken !== token) {
host.remove();
return;
}
container.empty();
container.textContent = source;
}
}
import { VUEPRESS_FILE_ICON_RULES } from "./generated/vuepressFileIcons";
interface OfflineIconStyle {
icon?: string | readonly string[];
openIcon?: string | readonly string[];
}
const DEFAULT_FILE_OFFLINE_ICON = "vscode-icons:default-file";
const DEFAULT_FOLDER_OFFLINE_ICON = "vscode-icons:default-folder";
const DEFAULT_FOLDER_OPEN_OFFLINE_ICON = "vscode-icons:default-folder-opened";
const ICONIFY_PREFIX_ALIASES: Record<string, string> = {
"vvscode-icons": "vscode-icons"
};
export function normalizeIconifyId(iconId: string): string {
const trimmed = iconId.trim();
const separator = trimmed.indexOf(":");
if (separator === -1) {
return trimmed;
}
const prefix = trimmed.slice(0, separator);
const name = trimmed.slice(separator + 1);
return `${ICONIFY_PREFIX_ALIASES[prefix] ?? prefix}:${name}`;
}
const OFFLINE_CANDIDATES = {
package: ["logos:npm-icon", "vscode-icons:file-type-npm", "vscode-icons:file-type-package", "vscode-icons:file-type-node"],
lock: ["vscode-icons:file-type-package", "vscode-icons:file-type-json"],
docs: ["vscode-icons:file-type-markdown", "vscode-icons:file-type-text"],
security: ["logos:github-icon", "vscode-icons:file-type-config"],
git: ["logos:git-icon", "vscode-icons:file-type-git"],
env: ["vscode-icons:file-type-dotenv", "vscode-icons:file-type-config"],
docker: ["logos:docker-icon", "vscode-icons:file-type-docker"],
config: ["vscode-icons:file-type-config", "vscode-icons:file-type-tsconfig"],
scripts: ["vscode-icons:file-type-shell", "vscode-icons:file-type-powershell", "vscode-icons:file-type-bat"],
prettier: ["vscode-icons:file-type-light-prettier", "vscode-icons:file-type-prettier", "vscode-icons:file-type-config"],
eslint: ["vscode-icons:file-type-eslint", "vscode-icons:file-type-eslint2", "vscode-icons:file-type-config"],
stylelint: ["vscode-icons:file-type-light-stylelint", "vscode-icons:file-type-stylelint", "vscode-icons:file-type-config"],
commitlint: ["vscode-icons:file-type-commitlint", "vscode-icons:file-type-config"],
editorconfig: ["vscode-icons:file-type-editorconfig", "vscode-icons:file-type-config"],
playwright: ["vscode-icons:file-type-playwright", "vscode-icons:file-type-jest"],
cypress: ["vscode-icons:file-type-cypress", "vscode-icons:file-type-jest"],
turbo: ["vscode-icons:file-type-light-turbo", "vscode-icons:file-type-config"],
nx: ["vscode-icons:file-type-light-nx", "vscode-icons:file-type-config"],
biome: ["vscode-icons:file-type-biome", "vscode-icons:file-type-config"],
react: ["vscode-icons:file-type-reactjs", "vscode-icons:file-type-js-official"],
next: ["vscode-icons:file-type-light-next", "vscode-icons:file-type-reactjs"],
nuxt: ["vscode-icons:file-type-nuxt", "vscode-icons:file-type-vue"],
svelte: ["vscode-icons:file-type-svelte", "vscode-icons:file-type-js-official"],
astro: ["vscode-icons:file-type-light-astro", "vscode-icons:file-type-js-official"],
deno: ["vscode-icons:file-type-light-deno", "vscode-icons:file-type-js-official"],
python: ["vscode-icons:file-type-python", "vscode-icons:file-type-package"],
go: ["vscode-icons:file-type-go-gopher", "vscode-icons:file-type-package"],
cargo: ["vscode-icons:file-type-cargo", "vscode-icons:file-type-rust"],
java: ["vscode-icons:file-type-java"],
php: ["vscode-icons:file-type-php3"],
c: ["vscode-icons:file-type-c"],
cpp: ["vscode-icons:file-type-cpp"],
csharp: ["vscode-icons:file-type-csharp"],
kotlin: ["vscode-icons:file-type-kotlin"],
ruby: ["vscode-icons:file-type-ruby"],
swift: ["vscode-icons:file-type-swift"],
zig: ["vscode-icons:file-type-zig"],
wasm: ["vscode-icons:file-type-wasm"],
mysql: ["vscode-icons:file-type-mysql", "vscode-icons:file-type-sql"],
pgsql: ["vscode-icons:file-type-pgsql", "vscode-icons:file-type-sql"],
srcFolder: ["vscode-icons:folder-type-src"],
srcFolderOpen: ["vscode-icons:folder-type-src-opened", "vscode-icons:folder-type-src"],
docsFolder: ["vscode-icons:folder-type-docs"],
docsFolderOpen: ["vscode-icons:folder-type-docs-opened", "vscode-icons:folder-type-docs"],
testFolder: ["vscode-icons:folder-type-test"],
testFolderOpen: ["vscode-icons:folder-type-test-opened", "vscode-icons:folder-type-test"],
distFolder: ["vscode-icons:folder-type-dist"],
distFolderOpen: ["vscode-icons:folder-type-dist-opened", "vscode-icons:folder-type-dist"],
publicFolder: ["vscode-icons:folder-type-public"],
publicFolderOpen: ["vscode-icons:folder-type-public-opened", "vscode-icons:folder-type-public"],
imagesFolder: ["vscode-icons:folder-type-images"],
imagesFolderOpen: ["vscode-icons:folder-type-images-opened", "vscode-icons:folder-type-images"],
assetsFolder: ["vscode-icons:folder-type-asset"],
assetsFolderOpen: ["vscode-icons:folder-type-asset-opened", "vscode-icons:folder-type-asset"],
scriptsFolder: ["vscode-icons:folder-type-script"],
scriptsFolderOpen: ["vscode-icons:folder-type-script-opened", "vscode-icons:folder-type-script"],
configFolder: ["vscode-icons:folder-type-config"],
configFolderOpen: ["vscode-icons:folder-type-config-opened", "vscode-icons:folder-type-config"],
nodeModulesFolder: ["vscode-icons:folder-type-light-node"],
nodeModulesFolderOpen: ["vscode-icons:folder-type-light-node-opened", "vscode-icons:folder-type-light-node"],
styleFolder: ["vscode-icons:folder-type-theme"],
styleFolderOpen: ["vscode-icons:folder-type-theme-opened", "vscode-icons:folder-type-theme"],
databaseFolder: ["vscode-icons:folder-type-db"],
databaseFolderOpen: ["vscode-icons:folder-type-db-opened", "vscode-icons:folder-type-db"],
componentFolder: ["vscode-icons:folder-type-component"],
componentFolderOpen: ["vscode-icons:folder-type-component-opened", "vscode-icons:folder-type-component"],
hookFolder: ["vscode-icons:folder-type-hook"],
hookFolderOpen: ["vscode-icons:folder-type-hook-opened", "vscode-icons:folder-type-hook"],
apiFolder: ["vscode-icons:folder-type-api"],
apiFolderOpen: ["vscode-icons:folder-type-api-opened", "vscode-icons:folder-type-api"],
serverFolder: ["vscode-icons:folder-type-server"],
serverFolderOpen: ["vscode-icons:folder-type-server-opened", "vscode-icons:folder-type-server"],
clientFolder: ["vscode-icons:folder-type-client"],
clientFolderOpen: ["vscode-icons:folder-type-client-opened", "vscode-icons:folder-type-client"],
libraryFolder: ["vscode-icons:folder-type-library"],
libraryFolderOpen: ["vscode-icons:folder-type-library-opened", "vscode-icons:folder-type-library"],
includeFolder: ["vscode-icons:folder-type-include"],
includeFolderOpen: ["vscode-icons:folder-type-include-opened", "vscode-icons:folder-type-include"],
localeFolder: ["vscode-icons:folder-type-locale"],
localeFolderOpen: ["vscode-icons:folder-type-locale-opened", "vscode-icons:folder-type-locale"],
pluginFolder: ["vscode-icons:folder-type-plugin"],
pluginFolderOpen: ["vscode-icons:folder-type-plugin-opened", "vscode-icons:folder-type-plugin"],
packageFolder: ["vscode-icons:folder-type-package"],
packageFolderOpen: ["vscode-icons:folder-type-package-opened", "vscode-icons:folder-type-package"],
appFolder: ["vscode-icons:folder-type-app"],
appFolderOpen: ["vscode-icons:folder-type-app-opened", "vscode-icons:folder-type-app"],
viewFolder: ["vscode-icons:folder-type-view"],
viewFolderOpen: ["vscode-icons:folder-type-view-opened", "vscode-icons:folder-type-view"],
modelFolder: ["vscode-icons:folder-type-model"],
modelFolderOpen: ["vscode-icons:folder-type-model-opened", "vscode-icons:folder-type-model"],
controllerFolder: ["vscode-icons:folder-type-controller"],
controllerFolderOpen: ["vscode-icons:folder-type-controller-opened", "vscode-icons:folder-type-controller"],
servicesFolder: ["vscode-icons:folder-type-services"],
servicesFolderOpen: ["vscode-icons:folder-type-services-opened", "vscode-icons:folder-type-services"],
typescript: ["vscode-icons:file-type-typescript-official"],
javascript: ["vscode-icons:file-type-js-official"],
vue: ["vscode-icons:file-type-vue"],
svelteType: ["vscode-icons:file-type-svelte"],
astroType: ["vscode-icons:file-type-light-astro"],
reactType: ["vscode-icons:file-type-reactjs"],
denoType: ["vscode-icons:file-type-light-deno"],
markdown: ["vscode-icons:file-type-markdown"],
text: ["vscode-icons:file-type-text"],
pdf: ["vscode-icons:file-type-pdf2", "vscode-icons:file-type-text"],
json: ["vscode-icons:file-type-json"],
yaml: ["vscode-icons:file-type-light-yaml"],
toml: ["vscode-icons:file-type-light-toml"],
ini: ["vscode-icons:file-type-light-ini", "vscode-icons:file-type-config"],
css: ["vscode-icons:file-type-css"],
scss: ["vscode-icons:file-type-scss", "vscode-icons:file-type-css"],
less: ["vscode-icons:file-type-less", "vscode-icons:file-type-css"],
stylus: ["vscode-icons:file-type-light-stylus", "vscode-icons:file-type-css"],
html: ["vscode-icons:file-type-html"],
xml: ["vscode-icons:file-type-xml"],
svg: ["vscode-icons:file-type-svg", "vscode-icons:file-type-image"],
image: ["vscode-icons:file-type-image"],
video: ["vscode-icons:file-type-video"],
audio: ["vscode-icons:file-type-audio"],
archive: ["vscode-icons:file-type-zip"],
database: ["vscode-icons:file-type-db", "vscode-icons:file-type-sql", "vscode-icons:file-type-sqlite"],
shell: ["vscode-icons:file-type-shell"],
powershell: ["vscode-icons:file-type-powershell", "vscode-icons:file-type-shell"],
batch: ["vscode-icons:file-type-bat", "vscode-icons:file-type-shell"],
javaType: ["vscode-icons:file-type-java"],
phpType: ["vscode-icons:file-type-php3"],
cType: ["vscode-icons:file-type-c"],
cppType: ["vscode-icons:file-type-cpp"],
csharpType: ["vscode-icons:file-type-csharp"],
kotlinType: ["vscode-icons:file-type-kotlin"],
rubyType: ["vscode-icons:file-type-ruby"],
swiftType: ["vscode-icons:file-type-swift"],
zigType: ["vscode-icons:file-type-zig"],
wasmType: ["vscode-icons:file-type-wasm"],
mysqlType: ["vscode-icons:file-type-mysql", "vscode-icons:file-type-sql"],
pgsqlType: ["vscode-icons:file-type-pgsql", "vscode-icons:file-type-sql"]
} as const;
const OFFLINE_NAMED_FILE_STYLES: Record<string, OfflineIconStyle> = {
"package.json": { icon: OFFLINE_CANDIDATES.package },
"npm-shrinkwrap.json": { icon: OFFLINE_CANDIDATES.lock },
"pnpm-workspace.yaml": { icon: "vscode-icons:file-type-light-pnpm" },
"pnpm-workspace.yml": { icon: "vscode-icons:file-type-light-pnpm" },
"package-lock.json": { icon: OFFLINE_CANDIDATES.lock },
"pnpm-lock.yaml": { icon: "vscode-icons:file-type-light-pnpm" },
"yarn.lock": { icon: "vscode-icons:file-type-yarn" },
"bun.lockb": { icon: "vscode-icons:file-type-bun" },
"pnpmfile.cjs": { icon: "vscode-icons:file-type-light-pnpm" },
"pnpmfile.mjs": { icon: "vscode-icons:file-type-light-pnpm" },
".npmrc": { icon: "vscode-icons:file-type-npm" },
".yarnrc": { icon: "vscode-icons:file-type-yarn" },
".yarnrc.yml": { icon: "vscode-icons:file-type-yarn" },
"bunfig.toml": { icon: "vscode-icons:file-type-bunfig" },
"deno.json": { icon: OFFLINE_CANDIDATES.deno },
"deno.jsonc": { icon: OFFLINE_CANDIDATES.deno },
"turbo.json": { icon: OFFLINE_CANDIDATES.turbo },
"nx.json": { icon: OFFLINE_CANDIDATES.nx },
"biome.json": { icon: OFFLINE_CANDIDATES.biome },
"biome.jsonc": { icon: OFFLINE_CANDIDATES.biome },
"readme": { icon: OFFLINE_CANDIDATES.docs },
"readme.md": { icon: OFFLINE_CANDIDATES.docs },
"readme.mdx": { icon: OFFLINE_CANDIDATES.docs },
"changelog.md": { icon: OFFLINE_CANDIDATES.docs },
"contributing.md": { icon: OFFLINE_CANDIDATES.docs },
"license": { icon: "vscode-icons:file-type-license" },
"license.md": { icon: "vscode-icons:file-type-license" },
"security.md": { icon: OFFLINE_CANDIDATES.security },
".gitignore": { icon: OFFLINE_CANDIDATES.git },
".gitattributes": { icon: OFFLINE_CANDIDATES.git },
".gitmodules": { icon: OFFLINE_CANDIDATES.git },
".env": { icon: OFFLINE_CANDIDATES.env },
".env.local": { icon: OFFLINE_CANDIDATES.env },
".env.development": { icon: OFFLINE_CANDIDATES.env },
".env.production": { icon: OFFLINE_CANDIDATES.env },
".env.example": { icon: OFFLINE_CANDIDATES.env },
"dockerfile": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.yml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.yaml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.override.yml": { icon: OFFLINE_CANDIDATES.docker },
"docker-compose.override.yaml": { icon: OFFLINE_CANDIDATES.docker },
".dockerignore": { icon: OFFLINE_CANDIDATES.docker },
"tsconfig.json": { icon: "vscode-icons:file-type-tsconfig" },
"tsconfig.base.json": { icon: "vscode-icons:file-type-tsconfig" },
"jsconfig.json": { icon: "vscode-icons:file-type-jsconfig" },
"vite.config.ts": { icon: "vscode-icons:file-type-vite" },
"vite.config.js": { icon: "vscode-icons:file-type-vite" },
"webpack.config.js": { icon: "vscode-icons:file-type-webpack" },
"rollup.config.js": { icon: "vscode-icons:file-type-rollup" },
"eslint.config.js": { icon: OFFLINE_CANDIDATES.eslint },
"eslint.config.mjs": { icon: OFFLINE_CANDIDATES.eslint },
"eslint.config.cjs": { icon: OFFLINE_CANDIDATES.eslint },
"prettier.config.js": { icon: OFFLINE_CANDIDATES.prettier },
"prettier.config.mjs": { icon: OFFLINE_CANDIDATES.prettier },
"prettier.config.cjs": { icon: OFFLINE_CANDIDATES.prettier },
"stylelint.config.js": { icon: OFFLINE_CANDIDATES.stylelint },
"stylelint.config.mjs": { icon: OFFLINE_CANDIDATES.stylelint },
"stylelint.config.cjs": { icon: OFFLINE_CANDIDATES.stylelint },
"vitest.config.ts": { icon: "vscode-icons:file-type-vitest" },
"jest.config.js": { icon: "vscode-icons:file-type-jest" },
"playwright.config.ts": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.js": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.mts": { icon: OFFLINE_CANDIDATES.playwright },
"playwright.config.mjs": { icon: OFFLINE_CANDIDATES.playwright },
"cypress.config.ts": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.js": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.mts": { icon: OFFLINE_CANDIDATES.cypress },
"cypress.config.mjs": { icon: OFFLINE_CANDIDATES.cypress },
"commitlint.config.js": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.cjs": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.mjs": { icon: OFFLINE_CANDIDATES.commitlint },
"commitlint.config.ts": { icon: OFFLINE_CANDIDATES.commitlint },
".editorconfig": { icon: OFFLINE_CANDIDATES.editorconfig },
".eslintrc": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.js": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.cjs": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.yml": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.yaml": { icon: OFFLINE_CANDIDATES.eslint },
".eslintrc.json": { icon: OFFLINE_CANDIDATES.eslint },
".prettierrc": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.js": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.cjs": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.yml": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.yaml": { icon: OFFLINE_CANDIDATES.prettier },
".prettierrc.json": { icon: OFFLINE_CANDIDATES.prettier },
".stylelintrc": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.js": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.cjs": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.yml": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.yaml": { icon: OFFLINE_CANDIDATES.stylelint },
".stylelintrc.json": { icon: OFFLINE_CANDIDATES.stylelint },
"tailwind.config.js": { icon: "vscode-icons:file-type-tailwind" },
"postcss.config.js": { icon: "vscode-icons:file-type-postcss" },
"makefile": { icon: OFFLINE_CANDIDATES.scripts },
".nvmrc": { icon: OFFLINE_CANDIDATES.package },
".node-version": { icon: OFFLINE_CANDIDATES.package },
"requirements.txt": { icon: OFFLINE_CANDIDATES.python },
".python-version": { icon: OFFLINE_CANDIDATES.python },
"pyproject.toml": { icon: OFFLINE_CANDIDATES.python },
"poetry.lock": { icon: OFFLINE_CANDIDATES.python },
"gemfile": { icon: OFFLINE_CANDIDATES.ruby },
"gemfile.lock": { icon: OFFLINE_CANDIDATES.ruby },
".ruby-version": { icon: OFFLINE_CANDIDATES.ruby },
"go.mod": { icon: OFFLINE_CANDIDATES.go },
"go.sum": { icon: OFFLINE_CANDIDATES.go },
"cargo.toml": { icon: OFFLINE_CANDIDATES.cargo },
"cargo.lock": { icon: OFFLINE_CANDIDATES.cargo },
"composer.json": { icon: OFFLINE_CANDIDATES.php },
"composer.lock": { icon: OFFLINE_CANDIDATES.php },
"pom.xml": { icon: OFFLINE_CANDIDATES.java },
"build.gradle": { icon: OFFLINE_CANDIDATES.java },
"build.gradle.kts": { icon: OFFLINE_CANDIDATES.kotlin },
"settings.gradle": { icon: OFFLINE_CANDIDATES.java },
"settings.gradle.kts": { icon: OFFLINE_CANDIDATES.kotlin }
};
const OFFLINE_FOLDER_STYLES: Record<string, OfflineIconStyle> = {
src: { icon: OFFLINE_CANDIDATES.srcFolder, openIcon: OFFLINE_CANDIDATES.srcFolderOpen },
source: { icon: OFFLINE_CANDIDATES.srcFolder, openIcon: OFFLINE_CANDIDATES.srcFolderOpen },
docs: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
doc: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
blog: { icon: OFFLINE_CANDIDATES.docsFolder, openIcon: OFFLINE_CANDIDATES.docsFolderOpen },
test: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
tests: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
__tests__: { icon: OFFLINE_CANDIDATES.testFolder, openIcon: OFFLINE_CANDIDATES.testFolderOpen },
dist: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
build: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
out: { icon: OFFLINE_CANDIDATES.distFolder, openIcon: OFFLINE_CANDIDATES.distFolderOpen },
public: { icon: OFFLINE_CANDIDATES.publicFolder, openIcon: OFFLINE_CANDIDATES.publicFolderOpen },
assets: { icon: OFFLINE_CANDIDATES.assetsFolder, openIcon: OFFLINE_CANDIDATES.assetsFolderOpen },
images: { icon: OFFLINE_CANDIDATES.imagesFolder, openIcon: OFFLINE_CANDIDATES.imagesFolderOpen },
img: { icon: OFFLINE_CANDIDATES.imagesFolder, openIcon: OFFLINE_CANDIDATES.imagesFolderOpen },
scripts: { icon: OFFLINE_CANDIDATES.scriptsFolder, openIcon: OFFLINE_CANDIDATES.scriptsFolderOpen },
script: { icon: OFFLINE_CANDIDATES.scriptsFolder, openIcon: OFFLINE_CANDIDATES.scriptsFolderOpen },
config: { icon: OFFLINE_CANDIDATES.configFolder, openIcon: OFFLINE_CANDIDATES.configFolderOpen },
node_modules: { icon: OFFLINE_CANDIDATES.nodeModulesFolder, openIcon: OFFLINE_CANDIDATES.nodeModulesFolderOpen },
style: { icon: OFFLINE_CANDIDATES.styleFolder, openIcon: OFFLINE_CANDIDATES.styleFolderOpen },
styles: { icon: OFFLINE_CANDIDATES.styleFolder, openIcon: OFFLINE_CANDIDATES.styleFolderOpen },
database: { icon: OFFLINE_CANDIDATES.databaseFolder, openIcon: OFFLINE_CANDIDATES.databaseFolderOpen },
db: { icon: OFFLINE_CANDIDATES.databaseFolder, openIcon: OFFLINE_CANDIDATES.databaseFolderOpen },
component: { icon: OFFLINE_CANDIDATES.componentFolder, openIcon: OFFLINE_CANDIDATES.componentFolderOpen },
components: { icon: OFFLINE_CANDIDATES.componentFolder, openIcon: OFFLINE_CANDIDATES.componentFolderOpen },
hook: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
hooks: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
composable: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
composables: { icon: OFFLINE_CANDIDATES.hookFolder, openIcon: OFFLINE_CANDIDATES.hookFolderOpen },
api: { icon: OFFLINE_CANDIDATES.apiFolder, openIcon: OFFLINE_CANDIDATES.apiFolderOpen },
apis: { icon: OFFLINE_CANDIDATES.apiFolder, openIcon: OFFLINE_CANDIDATES.apiFolderOpen },
server: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
servers: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
backend: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
backends: { icon: OFFLINE_CANDIDATES.serverFolder, openIcon: OFFLINE_CANDIDATES.serverFolderOpen },
client: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
clients: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
frontend: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
frontends: { icon: OFFLINE_CANDIDATES.clientFolder, openIcon: OFFLINE_CANDIDATES.clientFolderOpen },
lib: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
libs: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
library: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
libraries: { icon: OFFLINE_CANDIDATES.libraryFolder, openIcon: OFFLINE_CANDIDATES.libraryFolderOpen },
include: { icon: OFFLINE_CANDIDATES.includeFolder, openIcon: OFFLINE_CANDIDATES.includeFolderOpen },
includes: { icon: OFFLINE_CANDIDATES.includeFolder, openIcon: OFFLINE_CANDIDATES.includeFolderOpen },
locale: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
locales: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
i18n: { icon: OFFLINE_CANDIDATES.localeFolder, openIcon: OFFLINE_CANDIDATES.localeFolderOpen },
plugin: { icon: OFFLINE_CANDIDATES.pluginFolder, openIcon: OFFLINE_CANDIDATES.pluginFolderOpen },
plugins: { icon: OFFLINE_CANDIDATES.pluginFolder, openIcon: OFFLINE_CANDIDATES.pluginFolderOpen },
package: { icon: OFFLINE_CANDIDATES.packageFolder, openIcon: OFFLINE_CANDIDATES.packageFolderOpen },
packages: { icon: OFFLINE_CANDIDATES.packageFolder, openIcon: OFFLINE_CANDIDATES.packageFolderOpen },
app: { icon: OFFLINE_CANDIDATES.appFolder, openIcon: OFFLINE_CANDIDATES.appFolderOpen },
apps: { icon: OFFLINE_CANDIDATES.appFolder, openIcon: OFFLINE_CANDIDATES.appFolderOpen },
view: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
views: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
page: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
pages: { icon: OFFLINE_CANDIDATES.viewFolder, openIcon: OFFLINE_CANDIDATES.viewFolderOpen },
model: { icon: OFFLINE_CANDIDATES.modelFolder, openIcon: OFFLINE_CANDIDATES.modelFolderOpen },
models: { icon: OFFLINE_CANDIDATES.modelFolder, openIcon: OFFLINE_CANDIDATES.modelFolderOpen },
controller: { icon: OFFLINE_CANDIDATES.controllerFolder, openIcon: OFFLINE_CANDIDATES.controllerFolderOpen },
controllers: { icon: OFFLINE_CANDIDATES.controllerFolder, openIcon: OFFLINE_CANDIDATES.controllerFolderOpen },
service: { icon: OFFLINE_CANDIDATES.servicesFolder, openIcon: OFFLINE_CANDIDATES.servicesFolderOpen },
services: { icon: OFFLINE_CANDIDATES.servicesFolder, openIcon: OFFLINE_CANDIDATES.servicesFolderOpen }
};
const OFFLINE_EXTENSION_STYLES: Record<string, OfflineIconStyle> = {
".d.ts": { icon: OFFLINE_CANDIDATES.typescript },
".ts": { icon: OFFLINE_CANDIDATES.typescript },
".tsx": { icon: OFFLINE_CANDIDATES.typescript },
".mts": { icon: OFFLINE_CANDIDATES.typescript },
".cts": { icon: OFFLINE_CANDIDATES.typescript },
".js": { icon: OFFLINE_CANDIDATES.javascript },
".jsx": { icon: OFFLINE_CANDIDATES.javascript },
".mjs": { icon: OFFLINE_CANDIDATES.javascript },
".cjs": { icon: OFFLINE_CANDIDATES.javascript },
".react.tsx": { icon: OFFLINE_CANDIDATES.reactType },
".react.jsx": { icon: OFFLINE_CANDIDATES.reactType },
".vue": { icon: OFFLINE_CANDIDATES.vue },
".svelte": { icon: OFFLINE_CANDIDATES.svelteType },
".astro": { icon: OFFLINE_CANDIDATES.astroType },
".md": { icon: OFFLINE_CANDIDATES.markdown },
".mdx": { icon: OFFLINE_CANDIDATES.markdown },
".txt": { icon: OFFLINE_CANDIDATES.text },
".pdf": { icon: OFFLINE_CANDIDATES.pdf },
".json": { icon: OFFLINE_CANDIDATES.json },
".jsonc": { icon: OFFLINE_CANDIDATES.json },
".yaml": { icon: OFFLINE_CANDIDATES.yaml },
".yml": { icon: OFFLINE_CANDIDATES.yaml },
".toml": { icon: OFFLINE_CANDIDATES.toml },
".ini": { icon: OFFLINE_CANDIDATES.ini },
".conf": { icon: OFFLINE_CANDIDATES.ini },
".css": { icon: OFFLINE_CANDIDATES.css },
".scss": { icon: OFFLINE_CANDIDATES.scss },
".sass": { icon: OFFLINE_CANDIDATES.scss },
".less": { icon: OFFLINE_CANDIDATES.less },
".styl": { icon: OFFLINE_CANDIDATES.stylus },
".html": { icon: OFFLINE_CANDIDATES.html },
".xml": { icon: OFFLINE_CANDIDATES.xml },
".svg": { icon: OFFLINE_CANDIDATES.svg },
".png": { icon: OFFLINE_CANDIDATES.image },
".jpg": { icon: OFFLINE_CANDIDATES.image },
".jpeg": { icon: OFFLINE_CANDIDATES.image },
".gif": { icon: OFFLINE_CANDIDATES.image },
".webp": { icon: OFFLINE_CANDIDATES.image },
".avif": { icon: OFFLINE_CANDIDATES.image },
".mp4": { icon: OFFLINE_CANDIDATES.video },
".mov": { icon: OFFLINE_CANDIDATES.video },
".avi": { icon: OFFLINE_CANDIDATES.video },
".webm": { icon: OFFLINE_CANDIDATES.video },
".mp3": { icon: OFFLINE_CANDIDATES.audio },
".wav": { icon: OFFLINE_CANDIDATES.audio },
".ogg": { icon: OFFLINE_CANDIDATES.audio },
".m4a": { icon: OFFLINE_CANDIDATES.audio },
".zip": { icon: OFFLINE_CANDIDATES.archive },
".rar": { icon: OFFLINE_CANDIDATES.archive },
".7z": { icon: OFFLINE_CANDIDATES.archive },
".gz": { icon: OFFLINE_CANDIDATES.archive },
".tar": { icon: OFFLINE_CANDIDATES.archive },
".sql": { icon: OFFLINE_CANDIDATES.database },
".sqlite": { icon: OFFLINE_CANDIDATES.database },
".db": { icon: OFFLINE_CANDIDATES.database },
".mysql": { icon: OFFLINE_CANDIDATES.mysqlType },
".pgsql": { icon: OFFLINE_CANDIDATES.pgsqlType },
".java": { icon: OFFLINE_CANDIDATES.javaType },
".php": { icon: OFFLINE_CANDIDATES.phpType },
".c": { icon: OFFLINE_CANDIDATES.cType },
".h": { icon: OFFLINE_CANDIDATES.cType },
".cpp": { icon: OFFLINE_CANDIDATES.cppType },
".cc": { icon: OFFLINE_CANDIDATES.cppType },
".cxx": { icon: OFFLINE_CANDIDATES.cppType },
".hpp": { icon: OFFLINE_CANDIDATES.cppType },
".cs": { icon: OFFLINE_CANDIDATES.csharpType },
".kt": { icon: OFFLINE_CANDIDATES.kotlinType },
".kts": { icon: OFFLINE_CANDIDATES.kotlinType },
".rb": { icon: OFFLINE_CANDIDATES.rubyType },
".swift": { icon: OFFLINE_CANDIDATES.swiftType },
".zig": { icon: OFFLINE_CANDIDATES.zigType },
".wasm": { icon: OFFLINE_CANDIDATES.wasmType },
".sh": { icon: OFFLINE_CANDIDATES.shell },
".bash": { icon: OFFLINE_CANDIDATES.shell },
".zsh": { icon: OFFLINE_CANDIDATES.shell },
".ps1": { icon: OFFLINE_CANDIDATES.powershell },
".bat": { icon: OFFLINE_CANDIDATES.batch },
".cmd": { icon: OFFLINE_CANDIDATES.batch }
};
const OFFLINE_PARTIAL_STYLES: Array<{ include: string; style: OfflineIconStyle }> = [
{ include: "test", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "spec", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "mock", style: { icon: "vscode-icons:file-type-jest" } },
{ include: "playwright", style: { icon: OFFLINE_CANDIDATES.playwright } },
{ include: "cypress", style: { icon: OFFLINE_CANDIDATES.cypress } },
{ include: "config", style: { icon: OFFLINE_CANDIDATES.config } },
{ include: "eslint", style: { icon: OFFLINE_CANDIDATES.eslint } },
{ include: "prettier", style: { icon: OFFLINE_CANDIDATES.prettier } },
{ include: "stylelint", style: { icon: OFFLINE_CANDIDATES.stylelint } },
{ include: "commitlint", style: { icon: OFFLINE_CANDIDATES.commitlint } },
{ include: "biome", style: { icon: OFFLINE_CANDIDATES.biome } },
{ include: "turbo", style: { icon: OFFLINE_CANDIDATES.turbo } },
{ include: "nx", style: { icon: OFFLINE_CANDIDATES.nx } },
{ include: "react", style: { icon: OFFLINE_CANDIDATES.react } },
{ include: "next", style: { icon: OFFLINE_CANDIDATES.next } },
{ include: "nuxt", style: { icon: OFFLINE_CANDIDATES.nuxt } },
{ include: "svelte", style: { icon: OFFLINE_CANDIDATES.svelte } },
{ include: "astro", style: { icon: OFFLINE_CANDIDATES.astro } },
{ include: "deno", style: { icon: OFFLINE_CANDIDATES.deno } },
{ include: "ruby", style: { icon: OFFLINE_CANDIDATES.ruby } },
{ include: "kotlin", style: { icon: OFFLINE_CANDIDATES.kotlin } },
{ include: "swift", style: { icon: OFFLINE_CANDIDATES.swift } },
{ include: "zig", style: { icon: OFFLINE_CANDIDATES.zig } },
{ include: "java", style: { icon: OFFLINE_CANDIDATES.java } },
{ include: "php", style: { icon: OFFLINE_CANDIDATES.php } },
{ include: "csharp", style: { icon: OFFLINE_CANDIDATES.csharp } },
{ include: "postgres", style: { icon: OFFLINE_CANDIDATES.pgsql } },
{ include: "mysql", style: { icon: OFFLINE_CANDIDATES.mysql } },
{ include: "docker", style: { icon: OFFLINE_CANDIDATES.docker } },
{ include: "readme", style: { icon: OFFLINE_CANDIDATES.docs } },
{ include: "changelog", style: { icon: OFFLINE_CANDIDATES.docs } },
{ include: "license", style: { icon: "vscode-icons:file-type-license" } },
{ include: "security", style: { icon: OFFLINE_CANDIDATES.security } },
{ include: ".lock", style: { icon: OFFLINE_CANDIDATES.lock } }
];
function toStyleRecord(source: Record<string, string>): Record<string, OfflineIconStyle> {
const entries = Object.entries(source).map(([key, icon]) => [key.toLowerCase(), { icon }] as const);
return Object.fromEntries(entries);
}
const OFFLINE_VUEPRESS_NAMED_STYLES = toStyleRecord({
...VUEPRESS_FILE_ICON_RULES.named,
...VUEPRESS_FILE_ICON_RULES.files
});
const OFFLINE_VUEPRESS_FOLDER_STYLES = toStyleRecord(VUEPRESS_FILE_ICON_RULES.folders);
const OFFLINE_VUEPRESS_EXTENSION_STYLES = toStyleRecord(VUEPRESS_FILE_ICON_RULES.extensions);
const OFFLINE_VUEPRESS_PARTIAL_STYLES: Array<{ include: string; style: OfflineIconStyle }> = Object.entries(
VUEPRESS_FILE_ICON_RULES.partials
).map(([include, icon]) => ({
include: include.toLowerCase(),
style: { icon }
}));
function pickBaseName(value: string): string {
const normalized = value.replace(/\\/g, "/");
const segment = normalized.split("/").pop();
return segment ?? normalized;
}
function getExtensionCandidates(baseName: string): string[] {
const candidates: string[] = [];
let extension = baseName;
const firstDotIndex = extension.indexOf(".");
if (firstDotIndex === -1) {
return candidates;
}
extension = extension.slice(firstDotIndex);
while (extension !== "") {
candidates.push(extension);
const nextDotIndex = extension.indexOf(".", 1);
if (nextDotIndex === -1) {
break;
}
extension = extension.slice(nextDotIndex);
}
return candidates;
}
function resolveFirstIconifyName(icon: string | readonly string[] | undefined): string | undefined {
if (!icon) {
return undefined;
}
if (typeof icon === "string") {
const normalized = normalizeIconifyId(icon);
return normalized.includes(":") ? normalized : undefined;
}
if (Array.isArray(icon)) {
for (const candidate of icon) {
const normalized = normalizeIconifyId(candidate);
if (normalized.includes(":")) {
return normalized;
}
}
return undefined;
}
return undefined;
}
function resolveIconifyIdWithFallback(icon: string | readonly string[] | undefined, fallback: string): string | undefined {
return resolveFirstIconifyName(icon) ?? resolveFirstIconifyName(fallback);
}
export function resolveOnlineIconifyId(fileName: string, nodeType: "folder" | "file", expanded: boolean): string | undefined {
const normalizedPath = fileName.replace(/\\/g, "/").toLowerCase();
const baseName = pick
… (demo build 截断)import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parseAllBlocks, parseCollapseRawContent, parseTabsRawContent } from "./parser";
const __dirname = dirname(fileURLToPath(import.meta.url));
const complexPath = join(__dirname, "__fixtures__", "plume-complex-test.md");
function contentIsOnlyBlocks(
content: string,
blocks: ReturnType<typeof parseAllBlocks>
): boolean {
const lines = content.split(/\r?\n/);
const inBlock = (i: number): boolean =>
blocks.some((b) => i >= b.startLine && i <= b.endLine);
for (let i = 0; i < lines.length; i++) {
if (!lines[i].trim()) continue;
if (inBlock(i)) continue;
return false;
}
return true;
}
describe("parser", () => {
it("parses card-grid → card → collapse → code-tabs from plume-complex-test §3.2", () => {
const complex = readFileSync(complexPath, "utf8");
const grid = parseAllBlocks(complex).find(
(b) => b.type === "card-grid" && b.rawContent.includes("Backend")
);
expect(grid).toBeDefined();
const card = parseAllBlocks(grid!.rawContent).find((b) => b.type === "card");
expect(card).toBeDefined();
const collapse = parseAllBlocks(card!.rawContent).find((b) => b.type === "collapse");
expect(collapse).toBeDefined();
const { preamble, items: collapseItems } = parseCollapseRawContent(collapse!.rawContent);
expect(collapseItems).toHaveLength(2);
expect(preamble.trim()).toBe("");
const apiBody = collapseItems[0].body;
const blocks0 = parseAllBlocks(apiBody);
expect(blocks0).toHaveLength(1);
expect(blocks0[0].type).toBe("code-tabs");
expect(contentIsOnlyBlocks(apiBody, blocks0)).toBe(true);
const tabs = parseTabsRawContent(blocks0[0].rawContent);
expect(tabs).toHaveLength(2);
expect(tabs[0].title).toBe("GET");
expect(tabs[1].title).toBe("POST");
});
it("parses collapse title without blank line before body", () => {
const noBlank = `- API
::: code-tabs
@tab A
\`\`\`js
1
\`\`\`
:::`;
const nbItem = parseCollapseRawContent(noBlank).items[0];
expect(nbItem.body).toContain("code-tabs");
});
it("parses ::: tabs id=\"...\" attribute form", () => {
const md = `::: tabs id="install-tabs"
@tab npm
hi
:::`;
const block = parseAllBlocks(md).find((b) => b.type === "tabs");
expect(block).toBeDefined();
expect((block!.attrs as { id?: string }).id).toBe("install-tabs");
});
it("parses Plume containers opened with four or more colons", () => {
const md = `:::: file-tree title="Tree"
- docs/
::::
:::: code-tree title="Code" entry="src/main.ts"
\`\`\`ts title="src/main.ts"
export const ok = true
\`\`\`
::::
:::: tabs#pm
@tab npm
npm install
::::
:::: code-tabs#api
@tab GET
\`\`\`ts
fetch("/api")
\`\`\`
::::`;
const blocks = parseAllBlocks(md);
expect(blocks.map((b) => b.type)).toEqual([
"file-tree",
"code-tree",
"tabs",
"code-tabs"
]);
expect(blocks.map((b) => b.markerLen)).toEqual([4, 4, 4, 4]);
expect((blocks[0].attrs as { title?: string }).title).toBe("Tree");
expect((blocks[1].attrs as { title?: string }).title).toBe("Code");
expect((blocks[2].attrs as { id?: string }).id).toBe("pm");
expect((blocks[3].attrs as { id?: string }).id).toBe("api");
});
it("keeps shorter nested container fences inside a longer outer container", () => {
const md = `::::: card-grid cols="2"
::: card title="A"
body
:::
::: card title="B"
body
:::
:::::`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
expect(grid.markerLen).toBe(5);
const cards = parseAllBlocks(grid.rawContent).filter((b) => b.type === "card");
expect(cards).toHaveLength(2);
expect(contentIsOnlyBlocks(grid.rawContent, cards)).toBe(true);
});
it("parses card-grid nested card icon attrs", () => {
const md = `:::: card-grid
::: card title="卡片标题 1" icon="smile"
content one
:::
::: card title="卡片标题 2" icon="sparkles"
content two
:::
::::`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
const cards = parseAllBlocks(grid!.rawContent).filter((b) => b.type === "card");
expect(cards).toHaveLength(2);
expect((cards[0].attrs as { icon?: string }).icon).toBe("smile");
expect((cards[1].attrs as { icon?: string }).icon).toBe("sparkles");
expect(contentIsOnlyBlocks(grid!.rawContent, cards)).toBe(true);
});
it("parses collapse preamble before list items", () => {
const withIntro = `Intro paragraph.
- Panel A
text a
`;
const parsedIntro = parseCollapseRawContent(withIntro);
expect(parsedIntro.preamble).toContain("Intro");
expect(parsedIntro.items).toHaveLength(1);
});
it("parses Vue component card grid syntax", () => {
const md = `<CardGrid cols="2">
<Card title="One" icon="smile">
Card body.
</Card>
<RepoCard repo="vuepress/core" fullname />
</CardGrid>`;
const grid = parseAllBlocks(md)[0];
expect(grid?.type).toBe("card-grid");
expect((grid!.attrs as { cols?: string }).cols).toBe("2");
const children = parseAllBlocks(grid!.rawContent);
expect(children.map((b) => b.type)).toEqual(["card", "repo-card"]);
expect((children[0].attrs as { title?: string }).title).toBe("One");
expect((children[1].attrs as { repo?: string; fullname?: boolean }).repo).toBe("vuepress/core");
expect((children[1].attrs as { fullname?: boolean }).fullname).toBe(true);
});
it("parses Vue LinkCard body closed on the same line", () => {
const md = `<LinkCard title="Docs" href="https://example.com">
**Rich** description</LinkCard>`;
const block = parseAllBlocks(md)[0];
expect(block?.type).toBe("link-card");
expect(block.rawContent).toBe(" **Rich** description");
});
});
import type {
CardContainerAttrs,
CardGridContainerAttrs,
CardMasonryContainerAttrs,
RepoCardContainerAttrs,
LinkCardContainerAttrs,
ImageCardContainerAttrs,
FieldContainerAttrs,
FieldGroupContainerAttrs,
FlexContainerAttrs,
AlignContainerAttrs,
WindowContainerAttrs,
ChatContainerAttrs,
CollapseContainerAttrs,
CodeTreeContainerAttrs,
CodeTreeFileItem,
FileTreeContainerAttrs,
FileTreeIconMode,
FileTreeNode,
FileTreeNodeProps,
ParsedBlock,
PromptContainerAttrs,
TabItem,
TabsContainerAttrs,
CodeTabsContainerAttrs,
TimelineContainerAttrs,
TimelineLineStyle,
TimelinePlacement,
AlignContainerType
} from "./types";
const RE_FOCUS = /^\*\*(.*)\*\*(?:$|\s+)/;
const ELLIPSIS = "\u2026";
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_TAB_MARKER = /^\s*@tab(?::active)?\s*(.*)$/i;
// HTML 组件标签正则
type HtmlComponentTag = "Card" | "CardGrid" | "CardMasonry" | "RepoCard" | "LinkCard" | "ImageCard";
interface HtmlComponentOpen {
attrs: string;
afterOpen: string;
selfClosing: boolean;
}
interface HtmlComponentBlock {
rawContent: string;
endLine: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function matchHtmlComponentOpen(line: string, tag: HtmlComponentTag): HtmlComponentOpen | null {
const name = escapeRegExp(tag);
const selfClosing = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)\\/?>\\s*$`, "i"));
if (selfClosing && /\/>\s*$/.test(line)) {
return {
attrs: selfClosing[1] ?? "",
afterOpen: "",
selfClosing: true
};
}
const open = line.match(new RegExp(`^\\s*<${name}\\b([^>]*)>(.*)$`, "i"));
if (!open) {
return null;
}
return {
attrs: open[1] ?? "",
afterOpen: open[2] ?? "",
selfClosing: false
};
}
function splitAtHtmlComponentClose(line: string, tag: HtmlComponentTag): { before: string } | null {
const match = line.match(new RegExp(`^(.*?)<\\/${escapeRegExp(tag)}>\\s*$`, "i"));
if (!match) {
return null;
}
return { before: match[1] ?? "" };
}
function collectHtmlComponentBlock(
lines: string[],
startLine: number,
tag: HtmlComponentTag,
open: HtmlComponentOpen
): HtmlComponentBlock | null {
if (open.selfClosing) {
return { rawContent: "", endLine: startLine };
}
const content: string[] = [];
const firstClose = splitAtHtmlComponentClose(open.afterOpen, tag);
if (firstClose) {
if (firstClose.before.trim()) {
content.push(firstClose.before);
}
return { rawContent: content.join("\n"), endLine: startLine };
}
if (open.afterOpen.trim()) {
content.push(open.afterOpen);
}
let fenceChar = "";
let fenceLength = 0;
let sameTagDepth = 0;
for (let cursor = startLine + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
if (fenceLength > 0) {
content.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = current.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
content.push(current);
continue;
}
const nestedOpen = matchHtmlComponentOpen(current, tag);
if (nestedOpen && !nestedOpen.selfClosing) {
sameTagDepth += 1;
content.push(current);
continue;
}
const close = splitAtHtmlComponentClose(current, tag);
if (close) {
if (sameTagDepth === 0) {
if (close.before.trim()) {
content.push(close.before);
}
return { rawContent: content.join("\n"), endLine: cursor };
}
sameTagDepth -= 1;
content.push(current);
continue;
}
content.push(current);
}
return null;
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
function parseLinkCardAttrs(attrsStr: string): LinkCardContainerAttrs {
const attrs: LinkCardContainerAttrs = { href: "" };
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const target = parseAttrValue(attrsStr, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(attrsStr, "rel");
if (rel) attrs.rel = rel;
return attrs;
}
function parseCardAttrs(attrsStr: string): CardContainerAttrs {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(attrsStr, "icon");
if (icon) attrs.icon = icon;
return attrs;
}
function parseCardGridAttrs(attrsStr: string): CardGridContainerAttrs {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
return attrs;
}
function parseCardMasonryAttrs(attrsStr: string): CardMasonryContainerAttrs {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(attrsStr, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(attrsStr, "gap");
if (gap) attrs.gap = gap;
return attrs;
}
function parseRepoCardAttrs(attrsStr: string): RepoCardContainerAttrs | null {
const repo = parseAttrValue(attrsStr, "repo");
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(attrsStr, "provider");
if (provider === "github" || provider === "gitee") attrs.provider = provider;
if (/(^|\s)fullname(?:\s|=|$)/i.test(attrsStr)) {
const fullname = parseAttrValue(attrsStr, "fullname");
attrs.fullname = fullname ? fullname !== "false" : true;
}
return attrs;
}
function parseImageCardAttrs(attrsStr: string): ImageCardContainerAttrs {
const attrs: ImageCardContainerAttrs = { image: "" };
const image = parseAttrValue(attrsStr, "image");
if (image) attrs.image = image;
const title = parseAttrValue(attrsStr, "title");
if (title) attrs.title = title;
const description = parseAttrValue(attrsStr, "description");
if (description) attrs.description = description;
const href = parseAttrValue(attrsStr, "href");
if (href) attrs.href = href;
const author = parseAttrValue(attrsStr, "author");
if (author) attrs.author = author;
const date = parseAttrValue(attrsStr, "date");
if (date) attrs.date = date;
const width = parseAttrValue(attrsStr, "width");
if (width) attrs.width = width;
const center = parseAttrValue(attrsStr, "center");
if (center !== undefined) attrs.center = center !== "false";
else if (/(^|\s)center(\s|$)/.test(attrsStr)) attrs.center = true;
return attrs;
}
export function normalizeCodeTreePath(value: string): string {
return value
.trim()
.replace(/\\/g, "/")
.replace(/^\.\/+/, "")
.replace(/^\/+/, "");
}
function removeEndingSlash(value: string): string {
return value.endsWith("/") ? value.slice(0, -1) : value;
}
export function parseFileTreeRawContent(content: string): FileTreeNode[] {
const trimmed = content.trimEnd();
if (!trimmed) {
return [];
}
const lines = trimmed.split(/\r?\n/);
const root: FileTreeNode = {
filename: "",
type: "folder",
expanded: true,
level: -1,
children: []
};
const stack: FileTreeNode[] = [root];
const initialIndent = lines[0]?.match(/^\s*/)?.[0].length ?? 0;
for (const line of lines) {
const match = line.match(/^(\s*)-(.*)$/);
if (!match) {
continue;
}
const level = Math.floor((match[1].length - initialIndent) / 2);
const info = match[2].trim();
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
const parent = stack[stack.length - 1];
if (!parent) {
continue;
}
const node: FileTreeNode = {
level,
children: [],
...parseFileTreeNodeInfo(info)
};
parent.children.push(node);
stack.push(node);
}
return root.children;
}
export function parseFileTreeNodeInfo(info: string): FileTreeNodeProps {
let filename = "";
let comment = "";
let focus = false;
let expanded: boolean | undefined = true;
let type: "folder" | "file" = "file";
let diff: "add" | "remove" | undefined;
if (info.startsWith("++")) {
info = info.slice(2).trim();
diff = "add";
} else if (info.startsWith("--")) {
info = info.slice(2).trim();
diff = "remove";
}
info = info.replace(RE_FOCUS, (_matched, focusName: string) => {
filename = focusName;
focus = true;
return "";
});
if (filename === "" && !focus) {
const commentStart = info.indexOf("#");
filename = info.slice(0, commentStart === -1 ? info.length : commentStart).trim();
info = commentStart === -1 ? "" : info.slice(commentStart);
}
comment = info.trim();
if (filename.endsWith("/")) {
type = "folder";
expanded = false;
filename = removeEndingSlash(filename);
}
return {
filename,
comment,
focus,
expanded,
type,
diff
};
}
export function parseContainerHeader(line: string, fallbackIcon: FileTreeIconMode): FileTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*file-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: FileTreeContainerAttrs = {
icon: fallbackIcon
};
const attrRegex = /([a-zA-Z][\w-]*)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/g;
let attrMatch: RegExpExecArray | null;
while ((attrMatch = attrRegex.exec(tail)) !== null) {
const key = attrMatch[1];
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (key === "title") {
attrs.title = value;
}
if (key === "icon" && (value === "simple" || value === "colored")) {
attrs.icon = value;
}
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseCodeTreeContainerHeader(line: string, fallbackIcon: FileTreeIconMode): CodeTreeContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tree\b(.*)$/i);
if (!match) {
return null;
}
const tail = match[1] ?? "";
const attrs: CodeTreeContainerAttrs = {
icon: fallbackIcon
};
const title = parseAttrValue(tail, "title");
if (title) {
attrs.title = title;
}
const entry = parseAttrValue(tail, "entry");
if (entry) {
attrs.entry = normalizeCodeTreePath(entry);
}
const height = parseAttrValue(tail, "height");
if (height) {
attrs.height = height;
}
const icon = parseAttrValue(tail, "icon");
if (icon === "simple" || icon === "colored") {
attrs.icon = icon;
}
if (tail.includes(":simple-icon")) {
attrs.icon = "simple";
}
if (tail.includes(":colored-icon")) {
attrs.icon = "colored";
}
return attrs;
}
export function parseTabsContainerHeader(line: string): TabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*tabs\b(.*)$/i);
if (!match) {
return null;
}
const tail = (match[1] ?? "").trim();
const attrs: TabsContainerAttrs = {};
const idMatch = tail.match(/^#([^\s#]+)/);
if (idMatch?.[1]) {
attrs.id = idMatch[1];
} else {
const idAttr = parseAttrValue(tail, "id");
if (idAttr) {
attrs.id = idAttr;
}
}
return attrs;
}
export function isFileTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*file-tree\b/i.test(text.trim());
}
export function isCodeTreeOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tree\b/i.test(text.trim());
}
export function isTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*tabs\b/i.test(text.trim());
}
export function parseCodeTabsContainerHeader(line: string): CodeTabsContainerAttrs | null {
const match = line.trim().match(/^:{3,}\s*code-tabs\b(.*)$/i);
if (!match) {
return null;
}
const rest = (match[1] ?? "").trim();
const attrs: CodeTabsContainerAttrs = {};
// Syntax: ::: code-tabs#myid (hash form, matching original vuepress-theme-plume)
const hashMatch = rest.match(/^#([\w-]+)/);
if (hashMatch) {
attrs.id = hashMatch[1];
} else {
// Fallback: id="..." form for parity with other containers.
const id = parseAttrValue(rest, "id");
if (id) attrs.id = id;
}
return attrs;
}
export function isCodeTabsOpenMarker(text: string): boolean {
return /^:{3,}\s*code-tabs\b/i.test(text.trim());
}
export function isStepsOpenMarker(text: string): boolean {
return /^:{3,}\s*steps\b/i.test(text.trim());
}
export interface ParsedStepItem {
/** Markdown body of the step (title line + content), without the leading `N.` marker */
body: string;
}
const RE_STEP_LINE = /^\s*\d+[.)]\s+/;
/**
* Split steps container body into items. VuePress relies on markdown `ol`, but
* Obsidian breaks lists when `:::` containers appear inside `li` — we render
* one `<li>` per step and run markdown inside each item instead.
*/
export function parseStepsRawContent(rawContent: string): ParsedStepItem[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const lines = text.split(/\r?\n/);
const chunks: string[] = [];
let current: string[] = [];
const pushChunk = (): void => {
if (current.length === 0) {
return;
}
chunks.push(current.join("\n"));
current = [];
};
for (const line of lines) {
if (RE_STEP_LINE.test(line)) {
pushChunk();
current.push(line);
continue;
}
if (current.length > 0) {
current.push(line);
}
}
pushChunk();
const items: ParsedStepItem[] = [];
for (const chunk of chunks) {
const chunkLines = chunk.split(/\r?\n/);
if (chunkLines.length === 0) {
continue;
}
chunkLines[0] = chunkLines[0].replace(/^\s*\d+[.)]\s*/, "");
const body = chunkLines.join("\n").trim();
items.push({ body });
}
return items;
}
/**
* Remove common list-item indentation so fenced ``` inside steps parse correctly
* in Obsidian (indented fences are not recognized as code blocks).
*/
export function dedentStepBody(body: string): string {
const lines = body.split(/\r?\n/);
const positiveIndents: number[] = [];
for (const line of lines) {
if (!line.trim()) {
continue;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len > 0) {
positiveIndents.push(len);
}
}
if (positiveIndents.length === 0) {
return body;
}
const min = Math.min(...positiveIndents);
return lines
.map((line) => {
if (!line.trim()) {
return line;
}
const len = line.match(/^(\s*)/)?.[1].length ?? 0;
if (len >= min) {
return line.slice(min);
}
return line;
})
.join("\n");
}
export interface CollapseItem {
titleLines: string[];
body: string;
expand?: boolean;
}
export interface ParsedCollapseContent {
/** Markdown before the first list item (optional intro). */
preamble: string;
items: CollapseItem[];
}
function buildCollapseItem(rawLines: string[]): CollapseItem {
while (rawLines.length && rawLines[0].trim() === "") rawLines.shift();
while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop();
if (rawLines.length === 0) {
return { titleLines: [], body: "", expand: undefined };
}
const titleLines = [rawLines[0]];
let bodyStart = 1;
// 允许空行后正文(正文不缩进也能识别)
while (bodyStart < rawLines.length && rawLines[bodyStart].trim() === "") {
bodyStart += 1;
}
// 如果正文首行不是新列表项,则全部视为正文
let bodyRaw = "";
if (bodyStart < rawLines.length) {
bodyRaw = rawLines.slice(bodyStart).join("\n");
}
let expand: boolean | undefined;
titleLines[0] = titleLines[0].replace(/^:([+-])\s*/, (_, flag: string) => {
expand = flag === "+";
return "";
});
return {
titleLines,
body: dedentStepBody(bodyRaw),
expand
};
}
/**
* Parse `::: collapse` list body into optional preamble + panel items.
*/
export function parseCollapseRawContent(rawContent: string): ParsedCollapseContent {
const lines = rawContent.replace(/\r\n/g, "\n").split("\n");
const preambleLines: string[] = [];
const items: CollapseItem[] = [];
let current: string[] | null = null;
const itemStart = /^(?:[-*+]\s+|\d+[.)]\s+)/;
for (const line of lines) {
if (itemStart.test(line)) {
if (current) {
items.push(buildCollapseItem(current));
}
current = [line.replace(itemStart, "")];
continue;
}
if (current) {
current.push(line);
} else {
preambleLines.push(line);
}
}
if (current) {
items.push(buildCollapseItem(current));
}
const filtered = items.filter(
(item) => item.titleLines.length > 0 || item.body.trim().length > 0
);
const preamble = dedentStepBody(preambleLines.join("\n").replace(/^\n+|\n+$/g, ""));
if (filtered.length > 0) {
return { preamble, items: filtered };
}
const trimmed = rawContent.replace(/^\n+|\n+$/g, "");
if (!trimmed) {
return { preamble: "", items: [] };
}
return {
preamble: "",
items: [{ titleLines: [], body: dedentStepBody(trimmed) }]
};
}
/** Split flex body into separate block-level segments (e.g. two tables). */
export function splitFlexSegments(rawContent: string): string[] {
const text = rawContent.replace(/^\n+|\n+$/g, "");
if (!text) {
return [];
}
const parts = text
.split(/\n(?:[ \t]*\n)+/)
.map((part) => part.trim())
.filter(Boolean);
return parts.length > 0 ? parts : [text];
}
/** Parse flex header flags the same way as vuepress-plugin-md-power alignPlugin. */
export function parseFlexContainerAttrs(rest: string): FlexContainerAttrs {
const attrs: FlexContainerAttrs = {};
const gap = parseAttrValue(rest, "gap");
if (gap) {
attrs.gap = gap;
}
const flagSource = rest
.replace(/gap\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/gi, " ")
.trim()
.toLowerCase();
const flags = flagSource.split(/\s+/).filter(Boolean);
for (const flag of flags) {
if (flag === "start") {
attrs.align = "start";
} else if (flag === "end") {
attrs.align = "end";
} else if (flag === "center") {
attrs.align = "center";
} else if (flag === "between") {
attrs.justify = "between";
} else if (flag === "around") {
attrs.justify = "around";
} else if (flag === "column") {
attrs.column = true;
} else if (flag === "wrap") {
attrs.wrap = true;
}
}
if (flags.includes("center") && !attrs.justify) {
attrs.justify = "center";
}
return attrs;
}
export function parsePromptContainerHeader(line: string): (PromptContainerAttrs & { markerLen: number }) | null {
const match = line.trim().match(/^(:{3,})\s*(note|info|tip|warning|caution|details|important)\b(.*)$/i);
if (!match) {
return null;
}
const markerLen = match[1]?.length ?? 0;
const type = (match[2] ?? "").toLowerCase() as PromptContainerAttrs["type"];
const title = (match[3] ?? "").trim() || undefined;
return {
type,
title,
markerLen
};
}
export function isPromptContainerOpenMarker(text: string): boolean {
return /^:{3,}\s*(note|info|tip|warning|caution|details|important)\b/i.test(text.trim());
}
export function isFileTreeCloseMarker(text: string): boolean {
return text.trim() === ":::";
}
export function parseCodeTreeRawContent(content: string): CodeTreeFileItem[] {
const lines = content.split(/\r?\n/);
const files: CodeTreeFileItem[] = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const openMatch = line.match(RE_CODE_FENCE_OPEN);
if (!openMatch) {
continue;
}
const fence = openMatch[2];
const markerChar = fence[0];
const markerLength = fence.length;
const info = (openMatch[3] ?? "").trim();
const title = parseAttrValue(info, "title");
const isActive = /(?:^|\s):active(?:\s|$)/.test(info);
const languageToken = info.split(/\s+/)[0] ?? "";
const language = languageToken && !languageToken.startsWith(":") ? languageToken : "text";
const body: string[] = [];
let closed = false;
for (let cursor = lineIndex + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
const closeRegex = new RegExp(`^\\s*${markerChar}{${markerLength},}\\s*$`);
if (closeRegex.test(current)) {
lineIndex = cursor;
closed = true;
break;
}
body.push(current);
}
if (!closed) {
break;
}
if (!title) {
continue;
}
const filepath = normalizeCodeTreePath(title);
if (!filepath) {
continue;
}
files.push({
filepath,
language,
content: body.join("\n"),
active: isActive
});
}
return files;
}
function parseTabMarker(line: string): {
title: string;
value: string;
active: boolean;
} | null {
const match = line.match(RE_TAB_MARKER);
if (!match) {
return null;
}
const active = /@tab:active/i.test(line);
const raw = (match[1] ?? "").trim();
const hashIndex = raw.indexOf("#");
let title = raw;
let value = "";
if (hashIndex >= 0) {
title = raw.slice(0, hashIndex).trim();
value = raw.slice(hashIndex + 1).trim();
}
title ||= value;
value ||= title;
if (!title && !value) {
return null;
}
return {
title,
value,
active
};
}
export function parseTabsRawContent(content: string): TabItem[] {
const lines = content.split(/\r?\n/);
const tabs: TabItem[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const marker = parseTabMarker(lines[lineIndex]);
if (!marker) {
lineIndex += 1;
continue;
}
const body: string[] = [];
lineIndex += 1;
let fenceChar = "";
let fenceLength = 0;
while (lineIndex < lines.length) {
const current = lines[lineIndex];
if (fenceLength > 0) {
body.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
lineIndex += 1;
continue;
}
const openMatch = current.match(RE_CODE_FENCE_OPEN);
if (openMatch) {
const fence = openMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
body.push(current);
lineIndex += 1;
continue;
}
if (parseTabMarker(current)) {
break;
}
body.push(current);
lineIndex += 1;
}
tabs.push({
title: marker.title,
value: marker.value,
active: marker.active,
content: body.join("\n").replace(/^\n+|\n+$/g, "")
});
}
for (let index = 0; index < tabs.length; index += 1) {
const tab = tabs[index];
if (!tab.title) {
tab.title = `Tab ${index + 1}`;
}
if (!tab.value) {
tab.value = tab.title;
}
}
return tabs;
}
export function parseCodeTreeFileNodes(files: CodeTreeFileItem[]): FileTreeNode[] {
const nodes: FileTreeNode[] = [];
for (const file of files) {
const normalized = normalizeCodeTreePath(file.filepath);
if (!normalized) {
continue;
}
const parts = normalized.split("/").filter(Boolean);
let children = nodes;
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
const isFile = index === parts.length - 1;
let node = children.find((item) => {
return item.filename === part;
});
if (!node) {
node = {
filename: part,
filepath: isFile ? normalized : undefined,
type: isFile ? "file" : "folder",
expanded: true,
level: index,
children: []
};
children.push(node);
}
if (isFile) {
node.type = "file";
node.filepath = normalized;
continue;
}
node.type = "folder";
node.expanded = true;
children = node.children;
}
}
return nodes;
}
function listItemInlineText(item: HTMLLIElement): string {
const parts: string[] = [];
for (const node of Array.from(item.childNodes)) {
if (node instanceof HTMLElement && (node.tagName === "UL" || node.tagName === "OL")) {
break;
}
if (node instanceof HTMLElement && node.tagName === "STRONG") {
const strongText = (node.textContent ?? "").trim();
parts.push(`**${strongText}**`);
continue;
}
parts.push(node.textContent ?? "");
}
return parts.join("").replace(/\r?\n/g, " ").trim();
}
export function listElementToRawLines(list: HTMLElement, level = 0): string[] {
const lines: string[] = [];
for (const child of Array.from(list.children)) {
if (!(child instanceof HTMLLIElement)) {
continue;
}
const info = listItemInlineText(child);
if (info) {
lines.push(`${" ".repeat(level)}- ${info}`);
}
const nestedLists = Array.from(child.children).filter((nested) => {
return nested.tagName === "UL" || nested.tagName === "OL";
});
for (const nestedList of nestedLists) {
lines.push(...listElementToRawLines(nestedList as HTMLElement, level + 1));
}
}
return lines;
}
export function fileTreeToCMDText(nodes: FileTreeNode[], prefix = ""): string {
let content = prefix ? "" : ".\n";
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const lead = i === nodes.length - 1 ? "└── " : "├── ";
content += `${prefix}${lead}${node.filename}\n`;
const childNodes = node.children.filter((child) => {
return child.filename !== ELLIPSIS && child.filename !== "...";
});
if (childNodes.length > 0) {
const childPrefix = prefix + (i === nodes.length - 1 ? " " : "│ ");
content += fileTreeToCMDText(childNodes, childPrefix);
}
}
return content;
}
// ---------------------------------------------------------------------------
// Unified block scanner
// ---------------------------------------------------------------------------
const RE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
const RE_DEFAULT_ICON_FALLBACK: FileTreeIconMode = "colored";
const CODE_TREE_EMBED_RE_LINE = /^\s*@\[code-tree([^\]]*)\]\(([^)]*)\)\s*$/i;
interface ContainerHeaderInfo {
type: "file-tree" | "code-tree" | "tabs" | "code-tabs" | "steps" | "prompt" | "collapse" | "card" | "card-grid" | "card-masonry" | "repo-card" | "link-card" | "image-card" | "field" | "field-group" | "flex" | "align" | "window" | "chat" | "timeline";
markerLen: number;
attrs:
| FileTreeContainerAttrs
| CodeTreeContainerAttrs
| TabsContainerAttrs
| CodeTabsContainerAttrs
| PromptContainerAttrs
| CollapseContainerAttrs
| CardContainerAttrs
| CardGridContainerAttrs
| CardMasonryContainerAttrs
| RepoCardContainerAttrs
| LinkCardContainerAttrs
| ImageCardContainerAttrs
| FieldContainerAttrs
| FieldGroupContainerAttrs
| FlexContainerAttrs
| AlignContainerAttrs
| WindowContainerAttrs
| ChatContainerAttrs
| TimelineContainerAttrs;
}
function detectContainerOpen(line: string, fallbackIcon: FileTreeIconMode): ContainerHeaderInfo | null {
const trimmed = line.trim();
const match = trimmed.match(/^(:{3,})\s*([a-zA-Z][\w-]*)\b(.*)$/);
if (!match) {
return null;
}
const markerLen = match[1].length;
const keyword = match[2].toLowerCase();
const rest = match[3] ?? "";
if (keyword === "file-tree") {
const attrs = parseContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "file-tree", markerLen, attrs };
}
if (keyword === "code-tree") {
const attrs = parseCodeTreeContainerHeader(line, fallbackIcon);
if (!attrs) return null;
return { type: "code-tree", markerLen, attrs };
}
if (keyword === "tabs") {
const attrs = parseTabsContainerHeader(line);
if (!attrs) return null;
return { type: "tabs", markerLen, attrs };
}
if (keyword === "code-tabs") {
const attrs = parseCodeTabsContainerHeader(line);
if (!attrs) return null;
return { type: "code-tabs", markerLen, attrs };
}
if (keyword === "steps") {
return { type: "steps", markerLen, attrs: {} as TabsContainerAttrs };
}
if (keyword === "collapse") {
const attrs: CollapseContainerAttrs = {};
if (/(^|\s)accordion(\s|$|=)/i.test(rest)) {
const accordionVal = parseAttrValue(rest, "accordion");
attrs.accordion = accordionVal ? accordionVal !== "false" : true;
}
if (/(^|\s)expand(\s|$|=)/i.test(rest)) {
const expandVal = parseAttrValue(rest, "expand");
attrs.expand = expandVal ? expandVal !== "false" : true;
}
return { type: "collapse", markerLen, attrs };
}
if (keyword === "card") {
const attrs: CardContainerAttrs = {};
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
return { type: "card", markerLen, attrs };
}
if (keyword === "card-grid") {
const attrs: CardGridContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
return { type: "card-grid", markerLen, attrs };
}
if (keyword === "card-masonry") {
const attrs: CardMasonryContainerAttrs = {};
const cols = parseAttrValue(rest, "cols");
if (cols) attrs.cols = cols;
const gap = parseAttrValue(rest, "gap");
if (gap) attrs.gap = gap;
return { type: "card-masonry", markerLen, attrs };
}
if (keyword === "repo-card") {
// Accept either `repo="owner/name"` or a positional `owner/name` after
// the keyword (matches the convention used by `prompt` containers).
let repo = parseAttrValue(rest, "repo") ?? "";
if (!repo) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && positional.includes("/") && !positional.includes("=")) {
repo = positional;
}
}
if (!repo) return null;
const attrs: RepoCardContainerAttrs = { repo };
const provider = parseAttrValue(rest, "provider");
if (provider === "gitee" || provider === "github") attrs.provider = provider;
if (/(^|\s)fullname(\s|$|=)/i.test(rest)) {
const v = parseAttrValue(rest, "fullname");
attrs.fullname = v ? v !== "false" : true;
}
return { type: "repo-card", markerLen, attrs };
}
if (keyword === "link-card") {
// href is required and supports either `href="..."` or a positional URL
// after the keyword (matches the `repo-card` convention).
let href = parseAttrValue(rest, "href") ?? "";
if (!href) {
const positional = rest.trim().split(/\s+/)[0] ?? "";
if (positional && !positional.includes("=")) href = positional;
}
if (!href) return null;
const attrs: LinkCardContainerAttrs = { href };
const title = parseAttrValue(rest, "title");
if (title) attrs.title = title;
const icon = parseAttrValue(rest, "icon");
if (icon) attrs.icon = icon;
const description = parseAttrValue(rest, "description");
if (description) attrs.description = description;
const target = parseAttrValue(rest, "target");
if (target) attrs.target = target;
const rel = parseAttrValue(rest, "rel");
if (rel) attrs.rel = rel;
return { type: "link-card", markerLen, attrs
… (demo build 截断)import { App, MarkdownView, TFile, setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import { scanCodeFences, decorateCodeBlockTitles } from "../render";
import type { FileTreeIconMode } from "../types";
import { prepareIconifyIconElement, processIconifyIcons } from "../render/iconify-online";
/**
* Obsidian treats fenced-code info strings (e.g. title="foo") as cosmetic and
* does not re-run post-processors when only those change. This service tracks
* title signatures per file and patches or forces preview rebuilds.
*/
export class CodeFenceTitleService {
private lastTitleSig = new Map<string, string>();
private dirtyPreviewFiles = new Set<string>();
constructor(
private readonly app: App,
private getDefaultIconMode: () => FileTreeIconMode
) {}
seedBaseline(file: TFile, text: string): void {
const sig = this.buildTitleSignature(text);
if (!this.lastTitleSig.has(file.path)) {
this.lastTitleSig.set(file.path, sig);
}
}
reconcileWithText(file: TFile, text: string): void {
const fences = scanCodeFences(text).filter((f) => !!f.title);
const sig = JSON.stringify(fences.map((f) => f.title ?? ""));
const prevSig = this.lastTitleSig.get(file.path);
const titlesChanged = prevSig !== undefined && prevSig !== sig;
this.lastTitleSig.set(file.path, sig);
if (titlesChanged) {
this.dirtyPreviewFiles.add(file.path);
}
if (fences.length === 0) {
this.stripTitleWrappersForFile(file);
if (titlesChanged) {
this.refreshDirtyPreviews();
}
return;
}
this.patchTitleWrappersForFile(file, fences);
if (titlesChanged) {
this.refreshDirtyPreviews();
}
}
decorateSection(
rootElement: HTMLElement,
fileText: string,
lineStart: number,
lineEnd: number
): void {
const fences = scanCodeFences(fileText).filter(
(f) => f.openLine >= lineStart && f.openLine <= lineEnd
);
if (fences.length === 0) {
return;
}
decorateCodeBlockTitles(rootElement, fences, this.getDefaultIconMode());
}
refreshDirtyPreviews(): void {
if (this.dirtyPreviewFiles.size === 0) {
return;
}
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
continue;
}
const path = view.file?.path;
if (!path || !this.dirtyPreviewFiles.has(path)) {
continue;
}
if (view.getMode?.() !== "preview") {
continue;
}
try {
view.previewMode.rerender(true);
this.dirtyPreviewFiles.delete(path);
} catch (err) {
console.error("[theme-plume] preview rerender failed", err);
}
}
}
clear(): void {
this.lastTitleSig.clear();
this.dirtyPreviewFiles.clear();
}
private buildTitleSignature(text: string): string {
const fences = scanCodeFences(text).filter((f) => !!f.title);
return JSON.stringify(fences.map((f) => f.title ?? ""));
}
private stripTitleWrappersForFile(file: TFile): void {
this.forEachPreviewOfFile(file, (preview) => {
for (const wrapper of Array.from(
preview.querySelectorAll<HTMLElement>(".vp-code-block-title")
)) {
const pre = wrapper.querySelector("pre");
if (pre) {
wrapper.replaceWith(pre);
pre.removeAttribute("data-vp-code-title-done");
}
}
});
}
private patchTitleWrappersForFile(
file: TFile,
fences: ReturnType<typeof scanCodeFences>
): void {
this.forEachPreviewOfFile(file, (preview) => {
const wrappers = Array.from(
preview.querySelectorAll<HTMLElement>(".vp-code-block-title")
);
if (wrappers.length !== fences.length) {
return;
}
for (let i = 0; i < wrappers.length; i += 1) {
const wrapper = wrappers[i];
const newTitle = fences[i].title as string;
if (wrapper.dataset.title === newTitle) {
continue;
}
wrapper.dataset.title = newTitle;
const label = wrapper.querySelector<HTMLElement>(".vp-code-block-title-text");
if (!label) {
continue;
}
while (label.firstChild) {
label.removeChild(label.firstChild);
}
const iconHost = document.createElement("span");
iconHost.className = "vp-code-block-title-icon ft-icon";
const desc = resolveNodeIcon(newTitle, "file", false, this.getDefaultIconMode());
if (desc.colorClass) {
iconHost.classList.add(desc.colorClass);
}
if (desc.iconifyId) {
prepareIconifyIconElement(iconHost, desc.iconifyId);
void processIconifyIcons(iconHost);
} else {
setIcon(iconHost, desc.icon);
}
label.appendChild(iconHost);
label.appendChild(document.createTextNode(newTitle));
}
});
}
private forEachPreviewOfFile(file: TFile, fn: (preview: HTMLElement) => void): void {
const seen = new Set<HTMLElement>();
for (const leaf of this.app.workspace.getLeavesOfType("markdown")) {
const view = leaf.view;
if (!(view instanceof MarkdownView)) {
continue;
}
if (view.file?.path !== file.path) {
continue;
}
const roots: Array<HTMLElement | undefined | null> = [
view.previewMode?.containerEl,
view.contentEl
];
for (const root of roots) {
if (!root || seen.has(root)) {
continue;
}
seen.add(root);
fn(root);
}
}
}
}
import type { MarkdownPostProcessorContext, Plugin } from "obsidian";
import { parseAllBlocks } from "../parser";
import { renderInnerMarkdown, type BlockRenderContext } from "../render";
import type { FileTreeIconMode, ParsedBlock } from "../types";
import { hashString } from "../utils/hash";
import { CodeFenceTitleService } from "./code-fence-titles";
const HIDDEN_SECTION_CLASS = "plume-section-absorbed";
export interface PreviewPipelineOptions {
plugin: Plugin;
getDefaultIconMode: () => FileTreeIconMode;
getOrParseBlocks: (text: string, sourcePath: string) => ParsedBlock[];
/** Prefer unsaved editor buffer over section snapshot (info.text). */
getDocumentText?: (sourcePath: string, sectionSnapshot: string) => string;
isDocumentDirty?: (sourcePath: string) => boolean;
clearDocumentDirty?: (sourcePath: string) => void;
buildRenderContext: (
sourcePath: string,
ctx: MarkdownPostProcessorContext
) => BlockRenderContext;
}
interface LeadingEntry {
el: HTMLElement;
ctx: MarkdownPostProcessorContext;
}
/**
* Coordinates Obsidian's per-section post-processor with Plume's block model.
*
* Strategy (stable, battle-tested in this codebase):
* 1. Parse blocks from the full file text (section info always carries full text).
* 2. Leading section (contains block open line) renders the whole block via
* placeholder-based `renderInnerMarkdown` (no fighting markdown-it).
* 3. Interior sections are visually absorbed (zero height, not display:none)
* so outline scroll positions stay usable.
* 4. Interior edits schedule a leading-section refresh.
*/
export class PreviewPipeline {
private leadingSections = new Map<string, LeadingEntry>();
private pendingReRender = new Set<string>();
readonly codeFenceTitles: CodeFenceTitleService;
constructor(private readonly options: PreviewPipelineOptions) {
this.codeFenceTitles = new CodeFenceTitleService(
options.plugin.app,
options.getDefaultIconMode
);
}
clear(): void {
this.leadingSections.clear();
this.pendingReRender.clear();
this.codeFenceTitles.clear();
}
/** Drop section skip keys so the next post-process pass rebuilds blocks (e.g. after save). */
invalidateBlocksForFile(sourcePath: string): void {
for (const [key, entry] of this.leadingSections) {
if (!key.startsWith(`${sourcePath}::`)) {
continue;
}
delete entry.el.dataset.plumeBlockKey;
}
}
/** Re-run leading-section renderers (e.g. while preview is visible and the file is edited). */
refreshLeadingSectionsForFile(sourcePath: string): void {
for (const [key, entry] of this.leadingSections) {
if (!key.startsWith(`${sourcePath}::`)) {
continue;
}
if (!entry.el.isConnected) {
this.leadingSections.delete(key);
continue;
}
if (this.pendingReRender.has(key)) {
continue;
}
this.pendingReRender.add(key);
queueMicrotask(() => {
this.pendingReRender.delete(key);
const fresh = this.leadingSections.get(key);
if (!fresh?.el.isConnected) {
return;
}
delete fresh.el.dataset.plumeBlockKey;
void this.processSection(fresh.el, fresh.ctx).catch((err) => {
console.error("[theme-plume] leading refresh failed", err);
});
});
}
}
async processSection(
rootElement: HTMLElement,
ctx: MarkdownPostProcessorContext
): Promise<void> {
const info = ctx.getSectionInfo(rootElement);
if (!info) {
this.unhideSection(rootElement);
return;
}
const docText = this.options.getDocumentText?.(ctx.sourcePath, info.text) ?? info.text;
const blocks = this.options.getOrParseBlocks(docText, ctx.sourcePath);
if (blocks.length === 0) {
this.unhideSection(rootElement);
this.codeFenceTitles.decorateSection(
rootElement,
info.text,
info.lineStart,
info.lineEnd
);
return;
}
const sectionStart = info.lineStart;
const sectionEnd = info.lineEnd;
const overlapping = blocks.filter(
(b) => b.endLine >= sectionStart && b.startLine <= sectionEnd
);
if (overlapping.length === 0) {
this.unhideSection(rootElement);
this.codeFenceTitles.decorateSection(
rootElement,
info.text,
info.lineStart,
info.lineEnd
);
return;
}
const interior = overlapping.find((b) => b.startLine < sectionStart);
if (interior) {
this.absorbSection(rootElement);
for (const b of overlapping) {
if (b.startLine < sectionStart) {
this.scheduleLeadingReRender(ctx.sourcePath, b.startLine, rootElement);
}
}
return;
}
const lines = docText.split(/\r?\n/);
let renderEnd = sectionEnd;
for (const b of overlapping) {
if (b.endLine > renderEnd) {
renderEnd = b.endLine;
}
}
const slice = lines.slice(sectionStart, renderEnd + 1).join("\n");
const lineKey = overlapping.map((b) => `${b.startLine}:${b.endLine}`).join("|");
const blocksKey = overlapping.map((b) => hashString(b.rawContent)).join("|");
const blockKey = `${lineKey}|${hashString(slice)}|${blocksKey}`;
const snapshotInSync = docText === info.text;
const isDirty = this.options.isDocumentDirty?.(ctx.sourcePath) ?? false;
// 强制重新渲染的条件:文档脏了、快照不同步、或块key不匹配
const shouldRerender = isDirty || !snapshotInSync || rootElement.dataset.plumeBlockKey !== blockKey || rootElement.childElementCount === 0;
if (!shouldRerender) {
return;
}
rootElement.empty();
this.unhideSection(rootElement);
rootElement.dataset.plumeBlockKey = blockKey;
rootElement.classList.add("plume-has-block");
const renderCtx = this.options.buildRenderContext(ctx.sourcePath, ctx);
for (const b of overlapping) {
if (b.startLine >= sectionStart) {
const key = `${ctx.sourcePath}::${b.startLine}`;
this.leadingSections.set(key, { el: rootElement, ctx });
}
}
try {
await renderInnerMarkdown(rootElement, slice, renderCtx);
this.options.clearDocumentDirty?.(ctx.sourcePath);
} catch (err) {
console.error("[theme-plume] section render failed", err);
const errEl = rootElement.createDiv({ cls: "plume-render-error" });
errEl.createEl("p", {
text: "Obsidian Plume: block render failed. See developer console for details."
});
errEl.createEl("pre", { text: slice });
}
this.scheduleRemeasure(rootElement);
}
private scheduleRemeasure(el: HTMLElement): void {
if (!el.querySelector(".vp-card-masonry, .vp-card-grid")) {
return;
}
window.requestAnimationFrame(() => {
if (!el.isConnected) {
return;
}
try {
this.options.plugin.app.workspace.trigger("resize");
} catch {
/* best-effort */
}
});
}
private scheduleLeadingReRender(
sourcePath: string,
blockStartLine: number,
triggerEl: HTMLElement
): void {
const key = `${sourcePath}::${blockStartLine}`;
const entry = this.leadingSections.get(key);
if (!entry) {
return;
}
if (!entry.el.isConnected) {
this.leadingSections.delete(key);
return;
}
if (entry.el === triggerEl) {
return;
}
if (this.pendingReRender.has(key)) {
return;
}
this.pendingReRender.add(key);
queueMicrotask(() => {
this.pendingReRender.delete(key);
const fresh = this.leadingSections.get(key);
if (!fresh || !fresh.el.isConnected) {
return;
}
delete fresh.el.dataset.plumeBlockKey;
void this.processSection(fresh.el, fresh.ctx).catch((err) => {
console.error("[theme-plume] leading re-render failed", err);
});
});
}
private absorbSection(el: HTMLElement): void {
el.empty();
el.classList.add(HIDDEN_SECTION_CLASS);
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
private unhideSection(el: HTMLElement): void {
if (el.classList.contains(HIDDEN_SECTION_CLASS)) {
el.classList.remove(HIDDEN_SECTION_CLASS);
}
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
}
import type { MarkdownView } from "obsidian";
/**
* Keeps editor buffer + dirty state; refreshes Plume blocks without previewMode.set/rerender.
* Avoids scroll jumps and flicker from full preview rebuilds.
*/
export class PreviewDocumentSync {
private readonly liveText = new Map<string, string>();
private readonly dirtyPaths = new Set<string>();
private readonly scrollByPath = new Map<string, number>();
setLiveText(sourcePath: string, text: string): void {
this.liveText.set(sourcePath, text);
}
markDirty(sourcePath: string, text: string): void {
this.liveText.set(sourcePath, text);
this.dirtyPaths.add(sourcePath);
}
isDirty(sourcePath: string): boolean {
return this.dirtyPaths.has(sourcePath);
}
clearDirty(sourcePath: string): void {
this.dirtyPaths.delete(sourcePath);
}
getLiveText(sourcePath: string, fallback: string): string {
return this.liveText.get(sourcePath) ?? fallback;
}
deleteLive(sourcePath: string): void {
this.liveText.delete(sourcePath);
this.dirtyPaths.delete(sourcePath);
this.scrollByPath.delete(sourcePath);
}
rememberScroll(sourcePath: string, scrollY: number): void {
if (scrollY > 0) {
this.scrollByPath.set(sourcePath, scrollY);
}
}
resolveScrollRestore(view: MarkdownView, sourcePath: string): number {
try {
const live = view.previewMode.getScroll();
if (live > 0) {
return live;
}
} catch {
/* ignore */
}
const saved = this.scrollByPath.get(sourcePath);
if (saved !== undefined && saved > 0) {
return saved;
}
try {
return view.editor.getScrollInfo().top;
} catch {
return 0;
}
}
applyScroll(view: MarkdownView, sourcePath: string, scrollY: number): void {
if (scrollY <= 0) {
return;
}
const apply = (): void => {
if (!view.previewMode.containerEl.isConnected) {
return;
}
view.previewMode.applyScroll(scrollY);
this.scrollByPath.set(sourcePath, scrollY);
};
apply();
window.requestAnimationFrame(() => {
apply();
window.requestAnimationFrame(apply);
});
window.setTimeout(apply, 50);
window.setTimeout(apply, 150);
window.setTimeout(apply, 300);
}
/** Strip Plume cache attrs so the next section post-process pass rebuilds blocks. */
static invalidatePreviewDom(view: MarkdownView): void {
for (const el of Array.from(
view.previewMode.containerEl.querySelectorAll<HTMLElement>(
"[data-plume-block-key], .plume-has-block"
)
)) {
delete el.dataset.plumeBlockKey;
el.classList.remove("plume-has-block");
}
}
}
import { App, Component, type IconName, MarkdownPostProcessorContext, Notice, requestUrl, setIcon } from "obsidian";
import { renderPlumeMarkdown, type PlumeMarkdownContext } from "./markdown/plume-markdown";
import { applyVuepressMarkdownTransforms } from "./render/markdown-transforms";
import { resolveNodeIcon } from "./icons";
import { registerBlockRenderer } from "./render/block-registry";
import { prepareIconifyIconElement, processIconifyIcons } from "./render/iconify-online";
import { renderCollapseBlock } from "./render/blocks/collapse";
import {
decorateCodeBlockTitles,
scanCodeFenceTitles,
scanCodeFences
} from "./render/code-fence";
import {
type BlockRenderContext,
type PlumeRenderSettings,
toBlockRenderContext,
toPlumeMarkdownContext,
triggerPreviewReflow
} from "./render/context";
import {
BLOCK_PLACEHOLDER_ATTR,
BLOCK_PLACEHOLDER_CLASS,
contentIsOnlyBlocksAndBlankLines,
pruneEmptyMarkdownNodes,
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./render/pipeline";
import { renderTabbedContainer } from "./render/tabbed-container";
import { renderInlineMarkdownInto } from "./render/inline";
import {
fileTreeToCMDText,
normalizeCodeTreePath,
parseAllBlocks,
parseCodeTreeFileNodes,
parseCodeTreeRawContent,
parseFileTreeRawContent,
parseStepsRawContent,
dedentStepBody,
splitFlexSegments,
parseTabsRawContent
} from "./parser";
export type { BlockRenderContext, PlumeRenderSettings } from "./render/context";
export {
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./render/pipeline";
export {
scanCodeFenceTitles,
scanCodeFences,
decorateCodeBlockTitles,
decorateSubtreeCodeFences
} from "./render/code-fence";
import type {
CardContainerAttrs,
CardGridContainerAttrs,
CardMasonryContainerAttrs,
RepoCardContainerAttrs,
LinkCardContainerAttrs,
ImageCardContainerAttrs,
FieldContainerAttrs,
FlexContainerAttrs,
WindowContainerAttrs,
ChatContainerAttrs,
CollapseContainerAttrs,
CodeTabsContainerAttrs,
CodeTreeContainerAttrs,
CodeTreeFileItem,
FileTreeContainerAttrs,
FileTreeIconMode,
FileTreeNode,
ParsedBlock,
PromptContainerAttrs,
PromptContainerType,
TabItem,
TabsContainerAttrs,
TimelineContainerAttrs,
TimelineItemMeta,
TimelineLineStyle,
TimelinePlacement,
AlignContainerAttrs
} from "./types";
interface RenderTreeOptions {
nodes: FileTreeNode[];
attrs: FileTreeContainerAttrs;
defaultIconMode: FileTreeIconMode;
markdownContext?: PlumeMarkdownContext;
}
interface RenderCodeTreeOptions {
files: CodeTreeFileItem[];
attrs: CodeTreeContainerAttrs;
defaultIconMode: FileTreeIconMode;
markdownContext?: RenderTreeOptions["markdownContext"];
}
interface RenderStepsOptions {
content: string;
markdownContext?: RenderTreeOptions["markdownContext"];
defaultIconMode?: FileTreeIconMode;
}
interface RenderPromptContainerOptions {
attrs: PromptContainerAttrs;
content: string;
markdownContext?: RenderTreeOptions["markdownContext"];
}
interface PromptPlaceholderBlock {
attrs: PromptContainerAttrs;
content: string;
}
const ELLIPSIS = "\u2026";
let commentRenderToken = 0;
const PROMPT_HEADER_RE = /^(\s*)(:{3,})\s*(note|info|tip|warning|caution|details|important)\b(.*)$/i;
const PROMPT_PLACEHOLDER_CLASS = "vp-prompt-placeholder";
const PROMPT_PLACEHOLDER_ATTR = "data-vp-prompt-id";
const PROMPT_DEFAULT_TITLES: Record<PromptContainerType, string> = {
note: "NOTE",
info: "INFO",
tip: "TIP",
warning: "WARNING",
caution: "CAUTION",
details: "DETAILS",
important: "IMPORTANT"
};
const PROMPT_TYPE_ICONS: Record<PromptContainerType, string | null> = {
note: "pencil",
info: "info",
tip: "lightbulb",
warning: "alert-triangle",
caution: "alert-octagon",
// details uses the native chevron ::before; skip the icon
details: null,
important: "alert-circle"
};
function applyPromptTitleIcon(host: HTMLElement, type: PromptContainerType): void {
const iconName = PROMPT_TYPE_ICONS[type];
if (!iconName) return;
const span = document.createElement("span");
span.className = "vp-custom-container-icon";
span.setAttribute("aria-hidden", "true");
host.prepend(span);
try {
setIcon(span, iconName);
} catch {
/* setIcon may throw if Lucide name unknown; ignore */
}
}
async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.classList.add("plume-clipboard-fallback");
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
function createPlaceholderNode(level: number): FileTreeNode {
return {
filename: ELLIPSIS,
type: "file",
expanded: false,
level,
children: []
};
}
function flattenInlineParagraph(el: HTMLElement): void {
if (el.children.length !== 1) {
return;
}
const only = el.firstElementChild;
if (!(only instanceof HTMLElement) || only.tagName !== "P") {
return;
}
while (only.firstChild) {
el.appendChild(only.firstChild);
}
only.remove();
}
function renderCommentMarkdown(
commentEl: HTMLElement,
rawComment: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
if (!markdownContext) {
commentEl.textContent = rawComment;
return;
}
const markdown = rawComment.split("#").join("\\#");
const token = String(++commentRenderToken);
commentEl.dataset.vpftCommentToken = token;
void renderPlumeMarkdown(commentEl, markdown, markdownContext)
.then(() => {
if (commentEl.dataset.vpftCommentToken !== token || !commentEl.isConnected) {
return;
}
flattenInlineParagraph(commentEl);
})
.catch(() => {
if (commentEl.dataset.vpftCommentToken !== token || !commentEl.isConnected) {
return;
}
commentEl.textContent = rawComment;
});
}
function parsePromptHeaderLine(
line: string
): (PromptContainerAttrs & { markerLen: number; indent: string }) | null {
const match = line.match(PROMPT_HEADER_RE);
if (!match) {
return null;
}
const indent = match[1] ?? "";
const markerLen = match[2]?.length ?? 0;
const type = (match[3] ?? "").toLowerCase() as PromptContainerType;
const title = (match[4] ?? "").trim() || undefined;
return {
type,
title,
markerLen,
indent
};
}
function collectPromptPlaceholderBlocks(markdown: string): {
transformedMarkdown: string;
blocks: Map<string, PromptPlaceholderBlock>;
} {
const lines = markdown.split(/\r?\n/);
const transformedLines: string[] = [];
const blocks = new Map<string, PromptPlaceholderBlock>();
let lineIndex = 0;
while (lineIndex < lines.length) {
const line = lines[lineIndex];
const header = parsePromptHeaderLine(line);
if (!header) {
transformedLines.push(line);
lineIndex += 1;
continue;
}
const bodyLines: string[] = [];
let closeLine = -1;
let nestedContainerDepth = 0;
let fenceChar = "";
let fenceLength = 0;
for (let cursor = lineIndex + 1; cursor < lines.length; cursor += 1) {
const current = lines[cursor];
if (fenceLength > 0) {
bodyLines.push(current);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(current)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = current.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
bodyLines.push(current);
continue;
}
const closeMatch = current.match(/^\s*(:{3,})\s*$/);
if (closeMatch) {
const markerLen = closeMatch[1]?.length ?? 0;
if (markerLen >= header.markerLen && nestedContainerDepth === 0) {
closeLine = cursor;
break;
}
if (nestedContainerDepth > 0) {
nestedContainerDepth -= 1;
}
bodyLines.push(current);
continue;
}
if (/^\s*:{3,}\s*\S+/.test(current)) {
nestedContainerDepth += 1;
}
bodyLines.push(current);
}
if (closeLine === -1) {
transformedLines.push(line);
lineIndex += 1;
continue;
}
const normalizedBody = bodyLines.map((bodyLine) => {
if (!bodyLine.trim() || !header.indent) {
return bodyLine;
}
return bodyLine.startsWith(header.indent)
? bodyLine.slice(header.indent.length)
: bodyLine;
});
let dedentLength = Number.MAX_SAFE_INTEGER;
for (const bodyLine of normalizedBody) {
if (!bodyLine.trim()) {
continue;
}
const indentMatch = bodyLine.match(/^[\t ]*/);
const lineIndent = indentMatch?.[0].length ?? 0;
dedentLength = Math.min(dedentLength, lineIndent);
}
const finalBody = Number.isFinite(dedentLength) && dedentLength > 0
? normalizedBody.map((bodyLine) => {
if (!bodyLine.trim()) {
return bodyLine;
}
return bodyLine.slice(dedentLength);
})
: normalizedBody;
const id = `vp-prompt-${blocks.size + 1}`;
blocks.set(id, {
attrs: {
type: header.type,
title: header.title
},
content: finalBody.join("\n").replace(/^\n+|\n+$/g, "")
});
transformedLines.push(
`${header.indent}<div class="${PROMPT_PLACEHOLDER_CLASS}" ${PROMPT_PLACEHOLDER_ATTR}="${id}"></div>`
);
lineIndex = closeLine + 1;
}
return {
transformedMarkdown: transformedLines.join("\n"),
blocks
};
}
function renderMarkdownChunk(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
if (!markdown.trim()) {
return;
}
if (!markdownContext) {
container.textContent = markdown;
return;
}
void renderPlumeMarkdown(container, markdown, markdownContext);
}
function renderMarkdownWithPromptContainers(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
const source = markdown.trim();
if (!source) {
return;
}
const transformed = collectPromptPlaceholderBlocks(source);
if (transformed.blocks.size === 0) {
renderMarkdownChunk(container, markdown, markdownContext);
return;
}
if (!markdownContext) {
container.textContent = source;
return;
}
void renderPlumeMarkdown(container, transformed.transformedMarkdown, markdownContext)
.then(() => {
if (!container.isConnected) {
return;
}
const placeholders = Array.from(
container.querySelectorAll(`.${PROMPT_PLACEHOLDER_CLASS}[${PROMPT_PLACEHOLDER_ATTR}]`)
).filter((node): node is HTMLElement => {
return node instanceof HTMLElement;
});
for (const placeholder of placeholders) {
const id = placeholder.getAttribute(PROMPT_PLACEHOLDER_ATTR);
if (!id) {
continue;
}
const block = transformed.blocks.get(id);
if (!block) {
continue;
}
placeholder.removeAttribute(PROMPT_PLACEHOLDER_ATTR);
placeholder.classList.remove(PROMPT_PLACEHOLDER_CLASS);
placeholder.empty();
renderPromptContainerInto(placeholder, {
attrs: block.attrs,
content: block.content,
markdownContext
});
}
})
.catch(() => {
if (!container.isConnected) {
return;
}
container.empty();
container.textContent = source;
});
}
export function renderMarkdownWithPromptContainersInto(
container: HTMLElement,
markdown: string,
markdownContext?: RenderTreeOptions["markdownContext"]
): void {
renderMarkdownWithPromptContainers(container, markdown, markdownContext);
}
export function renderFileTreeInto(container: HTMLElement, options: RenderTreeOptions): void {
const mode = options.attrs.icon ?? options.defaultIconMode;
const wrapper = document.createElement("div");
wrapper.className = "vp-file-tree obsidian-vuepress-file-tree";
container.appendChild(wrapper);
if (options.attrs.title) {
const title = document.createElement("p");
title.className = "vp-file-tree-title";
title.textContent = options.attrs.title;
wrapper.appendChild(title);
}
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.className = "obsidian-file-tree-copy clickable-icon";
copyButton.setAttribute("aria-label", "Copy file tree");
setIcon(copyButton, "copy");
wrapper.appendChild(copyButton);
const cmdText = fileTreeToCMDText(options.nodes).trim();
copyButton.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
try {
await copyToClipboard(cmdText);
copyButton.classList.add("is-copied");
setIcon(copyButton, "check");
window.setTimeout(() => {
copyButton.classList.remove("is-copied");
setIcon(copyButton, "copy");
}, 1200);
} catch {
new Notice("Failed to copy file tree text.");
}
});
let activeInfoElement: HTMLElement | null = null;
const renderNodes = (parent: HTMLElement, nodes: FileTreeNode[], parentPath: string): void => {
for (const node of nodes) {
const nodeElement = document.createElement("div");
nodeElement.className = "vp-file-tree-node";
parent.appendChild(nodeElement);
const nodeChildren =
node.type === "folder" && node.children.length === 0
? [createPlaceholderNode(node.level + 1)]
: node.children;
const nodeType: "folder" | "file" = nodeChildren.length > 0 ? "folder" : node.type;
const isPlaceholder = node.filename === ELLIPSIS || node.filename === "...";
const info = document.createElement("p");
info.classList.add("vp-file-tree-info", nodeType);
info.style.setProperty("--file-tree-level", String(-node.level));
nodeElement.appendChild(info);
if (node.focus) {
info.classList.add("focus");
}
if (node.diff) {
info.classList.add("diff", node.diff);
}
const icon = isPlaceholder ? null : document.createElement("span");
if (icon) {
icon.className = "ft-icon";
info.appendChild(icon);
}
let expanded = nodeType === "folder" ? node.expanded !== false : false;
const group = nodeType === "folder" ? document.createElement("div") : null;
if (group) {
group.className = "group";
nodeElement.appendChild(group);
}
const applyIcon = (): void => {
if (!icon) {
return;
}
const iconDescriptor = resolveNodeIcon(node.filename, nodeType, expanded, mode);
icon.className = "ft-icon";
if (iconDescriptor.colorClass) {
icon.classList.add(iconDescriptor.colorClass);
}
if (iconDescriptor.iconifyId) {
prepareIconifyIconElement(icon, iconDescriptor.iconifyId);
void processIconifyIcons(icon);
return;
}
icon.classList.remove("ft-icon-online");
icon.empty();
setIcon(icon, iconDescriptor.icon);
};
const applyFolderState = (): void => {
if (nodeType !== "folder" || !group) {
return;
}
if (expanded) {
info.classList.add("expanded");
group.hidden = false;
} else {
info.classList.remove("expanded");
group.hidden = true;
}
applyIcon();
};
applyIcon();
const name = document.createElement("span");
name.classList.add("name", nodeType);
name.textContent = node.filename;
info.appendChild(name);
if (node.comment) {
const comment = document.createElement("span");
comment.className = "comment";
renderCommentMarkdown(comment, node.comment, options.markdownContext);
info.appendChild(comment);
}
const nodePath = parentPath ? `${parentPath}/${node.filename}` : node.filename;
info.dataset.path = nodePath;
if (group) {
applyFolderState();
renderNodes(group, nodeChildren, nodePath);
}
info.addEventListener("click", (event: MouseEvent) => {
if (isPlaceholder) {
return;
}
const target = event.target as HTMLElement;
if (nodeType === "folder") {
if (target.closest(".comment")) {
return;
}
expanded = !expanded;
applyFolderState();
if (options.markdownContext?.app) {
triggerPreviewReflow(options.markdownContext.app);
}
return;
}
if (activeInfoElement && activeInfoElement !== info) {
activeInfoElement.classList.remove("active");
}
info.classList.add("active");
activeInfoElement = info;
});
}
};
renderNodes(wrapper, options.nodes, "");
}
function normalizeHeightValue(height: string | undefined): string | undefined {
if (!height) {
return undefined;
}
const value = height.trim();
if (!value) {
return undefined;
}
if (/^\d+(?:\.\d+)?$/.test(value)) {
return `${value}px`;
}
return value;
}
function renderPlainCodeBlock(container: HTMLElement, language: string, content: string): void {
const pre = document.createElement("pre");
pre.className = "vp-code-tree-pre";
const code = document.createElement("code");
code.className = `language-${language || "text"}`;
code.textContent = content;
pre.appendChild(code);
container.appendChild(pre);
}
export function renderCodeTreeInto(container: HTMLElement, options: RenderCodeTreeOptions): void {
const normalizedFiles: CodeTreeFileItem[] = [];
const fileMap = new Map<string, CodeTreeFileItem>();
for (const file of options.files) {
const filepath = normalizeCodeTreePath(file.filepath);
if (!filepath) {
continue;
}
const existing = fileMap.get(filepath);
if (existing) {
if (file.active) {
existing.active = true;
}
continue;
}
const normalizedFile: CodeTreeFileItem = {
...file,
filepath,
language: file.language || "text"
};
fileMap.set(filepath, normalizedFile);
normalizedFiles.push(normalizedFile);
}
if (normalizedFiles.length === 0) {
return;
}
const mode = options.attrs.icon ?? options.defaultIconMode;
const wrapper = document.createElement("div");
wrapper.className = "vp-code-tree obsidian-vuepress-file-tree obsidian-vuepress-code-tree";
container.appendChild(wrapper);
if (options.attrs.title) {
const title = document.createElement("p");
title.className = "vp-code-tree-title";
title.textContent = options.attrs.title;
wrapper.appendChild(title);
}
const normalizedHeight = normalizeHeightValue(options.attrs.height);
if (normalizedHeight) {
wrapper.style.setProperty("--vp-code-tree-height", normalizedHeight);
}
const body = document.createElement("div");
body.className = "vp-code-tree-body";
wrapper.appendChild(body);
const nav = document.createElement("div");
nav.className = "vp-code-tree-nav";
body.appendChild(nav);
const panel = document.createElement("div");
panel.className = "vp-code-tree-panel";
body.appendChild(panel);
const panelHeader = document.createElement("div");
panelHeader.className = "vp-code-tree-panel-header";
panel.appendChild(panelHeader);
const panelEntry = document.createElement("span");
panelEntry.className = "vp-code-tree-panel-entry";
panelHeader.appendChild(panelEntry);
const panelContent = document.createElement("div");
panelContent.className = "vp-code-tree-panel-content";
panel.appendChild(panelContent);
const explicitEntry = options.attrs.entry ? normalizeCodeTreePath(options.attrs.entry) : "";
const initialPath =
(explicitEntry && fileMap.has(explicitEntry) ? explicitEntry : undefined)
?? normalizedFiles.find((file) => file.active)?.filepath
?? normalizedFiles[0].filepath;
const treeNodes = parseCodeTreeFileNodes(normalizedFiles);
let activePath = initialPath;
let activeInfoElement: HTMLElement | null = null;
let panelRenderToken = 0;
const fileInfoMap = new Map<string, HTMLElement>();
const setActiveInfo = (filepath: string): void => {
const next = fileInfoMap.get(filepath);
if (!(next instanceof HTMLElement)) {
return;
}
if (activeInfoElement && activeInfoElement !== next) {
activeInfoElement.classList.remove("active");
}
next.classList.add("active");
activeInfoElement = next;
};
const renderPanel = (filepath: string): void => {
const file = fileMap.get(filepath);
if (!file) {
return;
}
panelEntry.textContent = file.filepath;
panelContent.empty();
if (!options.markdownContext) {
renderPlainCodeBlock(panelContent, file.language, file.content);
return;
}
const token = String(++panelRenderToken);
panelContent.dataset.vpctRenderToken = token;
const markdown = `\`\`\`${file.language}\n${file.content}\n\`\`\``;
void renderPlumeMarkdown(panelContent, markdown, options.markdownContext)
.catch(() => {
if (panelContent.dataset.vpctRenderToken !== token || !panelContent.isConnected) {
return;
}
panelContent.empty();
renderPlainCodeBlock(panelContent, file.language, file.content);
});
};
const renderNodes = (parent: HTMLElement, nodes: FileTreeNode[], parentPath: string): void => {
for (const node of nodes) {
const nodeElement = document.createElement("div");
nodeElement.className = "vp-file-tree-node";
parent.appendChild(nodeElement);
const hasChildren = node.children.length > 0;
const nodeType: "folder" | "file" = hasChildren || node.type === "folder" ? "folder" : "file";
const info = document.createElement("p");
info.classList.add("vp-file-tree-info", nodeType);
info.style.setProperty("--file-tree-level", String(-node.level));
nodeElement.appendChild(info);
const icon = document.createElement("span");
icon.className = "ft-icon";
info.appendChild(icon);
let expanded = nodeType === "folder" ? node.expanded !== false : false;
const group = nodeType === "folder" ? document.createElement("div") : null;
if (group) {
group.className = "group";
nodeElement.appendChild(group);
}
const applyIcon = (): void => {
const iconDescriptor = resolveNodeIcon(node.filename, nodeType, expanded, mode);
icon.className = "ft-icon";
if (iconDescriptor.colorClass) {
icon.classList.add(iconDescriptor.colorClass);
}
if (iconDescriptor.iconifyId) {
prepareIconifyIconElement(icon, iconDescriptor.iconifyId);
void processIconifyIcons(icon);
return;
}
icon.classList.remove("ft-icon-online");
icon.empty();
setIcon(icon, iconDescriptor.icon);
};
const applyFolderState = (): void => {
if (nodeType !== "folder" || !group) {
return;
}
if (expanded) {
info.classList.add("expanded");
group.hidden = false;
} else {
info.classList.remove("expanded");
group.hidden = true;
}
applyIcon();
};
applyIcon();
const name = document.createElement("span");
name.classList.add("name", nodeType);
name.textContent = node.filename;
info.appendChild(name);
const currentPath = parentPath ? `${parentPath}/${node.filename}` : node.filename;
const filepath = normalizeCodeTreePath(node.filepath ?? currentPath);
info.dataset.path = filepath;
if (nodeType === "file") {
fileInfoMap.set(filepath, info);
}
if (group) {
applyFolderState();
renderNodes(group, node.children, currentPath);
}
info.addEventListener("click", () => {
if (nodeType === "folder") {
expanded = !expanded;
applyFolderState();
return;
}
if (!fileMap.has(filepath)) {
return;
}
activePath = filepath;
setActiveInfo(activePath);
renderPanel(activePath);
});
}
};
renderNodes(nav, treeNodes, "");
setActiveInfo(activePath);
renderPanel(activePath);
}
export function renderPromptContainerInto(container: HTMLElement, options: RenderPromptContainerOptions): void {
const type = options.attrs.type;
const title = options.attrs.title?.trim() || PROMPT_DEFAULT_TITLES[type];
const content = options.content.trim();
if (type === "details") {
const details = document.createElement("details");
details.className = "vp-custom-container obsidian-vuepress-prompt-container details";
container.appendChild(details);
const summary = document.createElement("summary");
summary.className = "vp-custom-container-title";
summary.textContent = title;
applyPromptTitleIcon(summary, type);
details.appendChild(summary);
const body = document.createElement("div");
body.className = "vp-custom-container-content";
details.appendChild(body);
renderMarkdownWithPromptContainers(body, content, options.markdownContext);
return;
}
const wrapper = document.createElement("div");
wrapper.className = `vp-custom-container obsidian-vuepress-prompt-container ${type}`;
container.appendChild(wrapper);
const titleElement = document.createElement("p");
titleElement.className = "vp-custom-container-title";
titleElement.textContent = title;
applyPromptTitleIcon(titleElement, type);
wrapper.appendChild(titleElement);
const body = document.createElement("div");
body.className = "vp-custom-container-content";
wrapper.appendChild(body);
renderMarkdownWithPromptContainers(body, content, options.markdownContext);
}
export function renderStepsInto(container: HTMLElement, options: RenderStepsOptions): void {
if (!options.markdownContext) {
void renderStepsContent(container, options.content);
return;
}
void renderStepsContent(
container,
options.content,
toBlockRenderContext(options.markdownContext, options.defaultIconMode ?? "colored")
);
}
// ===========================================================================
// Unified block rendering pipeline
// ===========================================================================
/** Collect masonry cells via Plume block renderers, or markdown for bare code fences. */
export async function gatherMasonryItems(
content: string,
ctx: BlockRenderContext
): Promise<HTMLElement[]> {
const trimmed = content.replace(/^\n+|\n+$/g, "");
if (!trimmed) {
return [];
}
const blocks = parseAllBlocks(trimmed, ctx.defaultIconMode);
if (blocks.length > 0 && contentIsOnlyBlocksAndBlankLines(trimmed, blocks)) {
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
if (document.body) {
document.body.appendChild(staging);
}
try {
await renderPlumeBlocksInto(staging, blocks, ctx);
const plumeItems = collectMasonryItems(staging);
if (plumeItems.length > 0) {
return plumeItems;
}
} finally {
staging.remove();
}
}
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
if (document.body) {
document.body.appendChild(staging);
}
try {
await renderInnerMarkdown(staging, trimmed, ctx);
return collectMasonryItems(staging);
} finally {
staging.remove();
}
}
async function buildMasonryItems(
content: string,
wrapper: HTMLElement,
ctx: BlockRenderContext
): Promise<HTMLElement[]> {
const items = await gatherMasonryItems(content, ctx);
if (items.length === 0) {
return [];
}
const staging = document.createElement("div");
staging.className = "plume-masonry-staging";
wrapper.appendChild(staging);
for (const item of items) {
staging.appendChild(item);
}
const collected = collectMasonryItems(staging);
staging.remove();
return collected;
}
interface NormalizedTabsAttrs extends TabsContainerAttrs {}
function normalizeTabs(rawTabs: TabItem[]): TabItem[] {
const seen = new Map<string, number>();
const out: TabItem[] = [];
for (let i = 0; i < rawTabs.length; i += 1) {
const t = rawTabs[i];
const title = t.title || `Tab ${i + 1}`;
const baseValue = t.value || title;
const dup = seen.get(baseValue) ?? 0;
seen.set(baseValue, dup + 1);
out.push({
...t,
title,
value: dup === 0 ? baseValue : `${baseValue}-${dup + 1}`
});
}
return out;
}
/**
* Dispatch a single parsed block to the appropriate renderer.
* Inner markdown content is rendered recursively via `renderInnerMarkdown`,
* so nested containers Just Work.
*/
export async function renderBlock(
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
): Promise<void> {
switch (block.type) {
case "file-tree": {
const nodes = parseFileTreeRawContent(block.rawContent);
if (nodes.length === 0) return;
renderFileTreeInto(container, {
nodes,
attrs: block.attrs as FileTreeContainerAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "code-tree": {
const files = parseCodeTreeRawContent(block.rawContent);
if (files.length === 0) return;
renderCodeTreeInto(container, {
files,
attrs: block.attrs as CodeTreeContainerAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "code-tree-embed": {
const attrs = block.attrs as CodeTreeContainerAttrs & { dirPath: string };
if (!ctx.resolveCodeTreeEmbed) return;
const files = await ctx.resolveCodeTreeEmbed(ctx.sourcePath, attrs.dirPath);
if (!files || files.length === 0) return;
const finalAttrs: CodeTreeContainerAttrs = { ...attrs };
delete (finalAttrs as Record<string, unknown>).dirPath;
if (!finalAttrs.entry) {
finalAttrs.entry = files[0].filepath;
}
renderCodeTreeInto(container, {
files,
attrs: finalAttrs,
defaultIconMode: ctx.defaultIconMode,
markdownContext: toPlumeMarkdownContext(ctx)
});
return;
}
case "tabs": {
const tabs = normalizeTabs(parseTabsRawContent(block.rawContent));
if (tabs.length === 0) return;
await renderTabsBlock(container, tabs, block.attrs as NormalizedTabsAttrs, ctx);
return;
}
case "code-tabs": {
const tabs = normalizeTabs(parseTabsRawContent(block.rawContent));
if (tabs.length === 0) return;
await renderCodeTabsBlock(container, tabs, block.attrs as CodeTabsContainerAttrs, ctx);
return;
}
case "steps": {
await renderStepsBlock(container, block.rawContent, ctx);
return;
}
case "prompt": {
await renderPromptBlock(container, block.rawContent, block.attrs as PromptContainerAttrs, ctx);
return;
}
case "collapse": {
await renderCollapseBlock(container, block.rawContent, block.attrs as CollapseContainerAttrs, ctx);
return;
}
case "card": {
await renderCardBlock(container, block.rawContent, block.attrs as CardContainerAttrs, ctx);
return;
}
case "card-grid": {
await renderCardGridBlock(container, block.rawContent, block.attrs as CardGridContainerAttrs, ct
… (demo build 截断)const BADGE_TYPES = new Set(["tip", "info", "warning", "danger", "note", "important"]);
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
function buildBadgeHtml(attrs: string, body = ""): string {
const type = parseAttrValue(attrs, "type") ?? "tip";
const text = parseAttrValue(attrs, "text") ?? body.trim() ?? "";
const color = parseAttrValue(attrs, "color");
const bgColor =
parseAttrValue(attrs, "bg-color")
?? parseAttrValue(attrs, "bgColor")
?? parseAttrValue(attrs, "bgcolor");
const borderColor =
parseAttrValue(attrs, "border-color")
?? parseAttrValue(attrs, "borderColor")
?? parseAttrValue(attrs, "bordercolor");
const normalized = type.toLowerCase();
const cls = normalized.replace(/[^a-z0-9_-]/g, "") || "tip";
const classes = ["vp-badge", cls];
if (!BADGE_TYPES.has(cls) && !color && !bgColor && !borderColor) {
classes.push("tip");
}
const style: string[] = [];
if (color) style.push(`color:${escapeHtml(color)}`);
if (bgColor) style.push(`background-color:${escapeHtml(bgColor)}`);
if (borderColor) style.push(`border-color:${escapeHtml(borderColor)}`);
const styleAttr = style.length > 0 ? ` style="${style.join(";")}"` : "";
return `<span class="${classes.join(" ")}"${styleAttr}>${escapeHtml(text)}</span>`;
}
function replaceBadgeTagsInLine(line: string): string {
return line
.replace(/<Badge\b([^>]*)\/>/gi, (_matched, attrs: string) => {
return buildBadgeHtml(attrs);
})
.replace(/<Badge\b([^>]*)>(.*?)<\/Badge>/gi, (_matched, attrs: string, body: string) => {
return buildBadgeHtml(attrs, body);
});
}
export function replaceBadgeTagsInMarkdown(markdown: string): string {
if (!/<Badge\b/i.test(markdown)) {
return markdown;
}
const lines = markdown.split(/\r?\n/);
const out: string[] = [];
let fenceChar = "";
let fenceLength = 0;
for (const line of lines) {
if (fenceLength > 0) {
out.push(line);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(line)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = line.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
out.push(line);
continue;
}
out.push(replaceBadgeTagsInLine(line));
}
return out.join("\n");
}
import type { BlockRenderContext } from "./context";
import type { ParsedBlock } from "../types";
export type BlockRenderer = (
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
) => Promise<void>;
let renderBlockImpl: BlockRenderer | null = null;
export function registerBlockRenderer(impl: BlockRenderer): void {
renderBlockImpl = impl;
}
export async function invokeBlockRenderer(
container: HTMLElement,
block: ParsedBlock,
ctx: BlockRenderContext
): Promise<void> {
if (!renderBlockImpl) {
throw new Error("[theme-plume] block renderer not registered");
}
await renderBlockImpl(container, block, ctx);
}
import { setIcon } from "obsidian";
import { parseCollapseRawContent } from "../../parser";
import type { CollapseContainerAttrs } from "../../types";
import { hashString } from "../../utils/hash";
import type { BlockRenderContext } from "../context";
import { renderInlineMarkdownInto } from "../inline";
import { renderNestedMarkdownContent } from "../pipeline";
interface CollapseItemMeta {
expand?: boolean;
}
function shouldEagerRenderCollapseBody(
details: HTMLDetailsElement,
index: number,
attrs: CollapseContainerAttrs,
activeIndex: number,
ctx: BlockRenderContext
): boolean {
if (!ctx.settings?.collapseLazyBodies) {
return true;
}
if (!details.open) {
return false;
}
if (attrs.accordion) {
return index === activeIndex;
}
return true;
}
function createCollapseDetailsElement(
index: number,
attrs: CollapseContainerAttrs,
itemMeta: CollapseItemMeta[],
activeIndex: number
): HTMLDetailsElement {
const details = document.createElement("details");
details.className = "vp-collapse-item";
details.setAttribute("role", "group");
const meta = itemMeta[index];
const expanded = attrs.accordion
? index === activeIndex
: (meta?.expand ?? attrs.expand ?? false);
details.open = expanded;
details.dataset.index = String(index);
const summary = document.createElement("summary");
summary.className = "vp-collapse-header";
summary.setAttribute("role", "button");
summary.setAttribute("tabindex", "0");
summary.setAttribute("aria-expanded", expanded ? "true" : "false");
summary.id = `vp-collapse-summary-${index}`;
details.appendChild(summary);
const chevron = document.createElement("span");
chevron.className = "vp-collapse-chevron";
chevron.setAttribute("aria-hidden", "true");
setIcon(chevron, "chevron-right");
summary.appendChild(chevron);
const title = document.createElement("span");
title.className = "vp-collapse-title";
summary.appendChild(title);
const content = document.createElement("div");
content.className = "vp-collapse-content";
content.setAttribute("role", "region");
content.setAttribute("aria-labelledby", summary.id);
content.setAttribute("tabindex", "0");
details.appendChild(content);
const inner = document.createElement("div");
inner.className = "vp-collapse-content-inner";
content.appendChild(inner);
return details;
}
function bindCollapseAccordion(wrapper: HTMLElement, details: HTMLDetailsElement): void {
details.addEventListener("toggle", () => {
if (!details.open) {
return;
}
for (const sibling of Array.from(
wrapper.querySelectorAll<HTMLDetailsElement>(":scope > .vp-collapse-item")
)) {
if (sibling !== details) {
sibling.open = false;
}
}
});
}
function scheduleCollapseBody(
details: HTMLDetailsElement,
inner: HTMLElement,
body: string,
ctx: BlockRenderContext,
eager: boolean
): void {
const bodyRevision = hashString(body);
const mountBody = async (): Promise<void> => {
if (
inner.dataset.plumeCollapseBodyRev === bodyRevision
&& inner.childElementCount > 0
) {
return;
}
inner.dataset.plumeCollapseBodyRev = bodyRevision;
inner.dataset.plumeCollapseBody = "1";
inner.empty();
await renderNestedMarkdownContent(inner, body, ctx);
};
if (eager) {
void mountBody();
return;
}
details.addEventListener("toggle", () => {
if (details.open) {
void mountBody();
}
});
}
export async function renderCollapseBlock(
container: HTMLElement,
rawContent: string,
attrs: CollapseContainerAttrs,
ctx: BlockRenderContext
): Promise<void> {
const content = rawContent.replace(/^\n+|\n+$/g, "");
if (!content) {
return;
}
const { preamble, items } = parseCollapseRawContent(content);
if (items.length === 0 && !preamble.trim()) {
return;
}
const wrapper = document.createElement("div");
wrapper.className = "vp-collapse obsidian-vuepress-collapse";
if (attrs.accordion) {
wrapper.dataset.accordion = "true";
}
container.appendChild(wrapper);
if (preamble.trim()) {
const intro = document.createElement("div");
intro.className = "vp-collapse-preamble";
wrapper.appendChild(intro);
await renderNestedMarkdownContent(intro, preamble, ctx);
}
let activeIndex = -1;
if (attrs.accordion) {
activeIndex = items.findIndex((item) => item.expand === true);
if (activeIndex === -1 && attrs.expand) {
activeIndex = 0;
}
}
for (const [index, item] of items.entries()) {
const details = createCollapseDetailsElement(
index,
attrs,
items.map((i) => ({ expand: i.expand })),
activeIndex
);
wrapper.appendChild(details);
const title = details.querySelector(".vp-collapse-title");
const inner = details.querySelector(".vp-collapse-content-inner");
const titleText = item.titleLines.join(" ").trim();
if (title instanceof HTMLElement && titleText) {
if (/[*_[\]`]/.test(titleText)) {
await renderInlineMarkdownInto(title, titleText, ctx, { phrasingOnly: true });
} else {
title.textContent = titleText;
}
}
if (inner instanceof HTMLElement && item.body.trim()) {
const eager = shouldEagerRenderCollapseBody(details, index, attrs, activeIndex, ctx);
scheduleCollapseBody(details, inner, item.body, ctx, eager);
}
if (attrs.accordion) {
bindCollapseAccordion(wrapper, details);
}
}
}
import { setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import type { FileTreeIconMode } from "../types";
import { prepareIconifyIconElement, processIconifyIcons } from "./iconify-online";
export const CODE_TITLE_PROCESSED_ATTR = "data-vp-code-title-done";
export function scanCodeFenceTitles(markdown: string): Array<{ title?: string }> {
return scanCodeFences(markdown).map((f) => ({ title: f.title }));
}
export function scanCodeFences(
markdown: string
): Array<{ title?: string; openLine: number; closeLine: number }> {
const lines = markdown.split(/\r?\n/);
const result: Array<{ title?: string; openLine: number; closeLine: number }> = [];
let fenceChar = "";
let fenceLen = 0;
let openLine = -1;
let currentTitle: string | undefined;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (fenceLen > 0) {
const closeRe = new RegExp(`^\\s*${fenceChar}{${fenceLen},}\\s*$`);
if (closeRe.test(line)) {
result.push({ title: currentTitle, openLine, closeLine: i });
fenceChar = "";
fenceLen = 0;
openLine = -1;
currentTitle = undefined;
}
continue;
}
const open = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
if (!open) continue;
fenceChar = open[2][0];
fenceLen = open[2].length;
openLine = i;
const info = open[3] ?? "";
const tm = info.match(/\btitle\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+))/);
currentTitle = tm ? (tm[1] ?? tm[2] ?? tm[3]) : undefined;
}
if (fenceLen > 0 && openLine >= 0) {
result.push({ title: currentTitle, openLine, closeLine: lines.length - 1 });
}
return result;
}
function listCodeBlockPres(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll("pre")).filter((pre) => {
const first = pre.firstElementChild;
return first != null && first.tagName === "CODE";
});
}
function resolveCodeBlockIconFilename(title: string, pre: HTMLElement | null): string {
const trimmed = title.trim();
if (trimmed.includes(".")) {
return trimmed;
}
const code = pre?.querySelector("code");
const langMatch = code?.className.match(/\blanguage-([\w+#-]+)\b/i);
const lang = langMatch?.[1]?.replace(/[#+].*$/, "");
if (lang && lang !== "plaintext" && lang !== "text") {
return `${trimmed || "file"}.${lang}`;
}
return trimmed || "file.txt";
}
function applyCodeTitleIcon(
host: HTMLElement,
title: string,
mode: FileTreeIconMode,
pre?: HTMLElement | null
): void {
const fileName = resolveCodeBlockIconFilename(title, pre ?? null);
const desc = resolveNodeIcon(fileName, "file", false, mode);
host.className = "vp-code-block-title-icon ft-icon";
if (desc.colorClass) {
host.classList.add(desc.colorClass);
}
host.empty();
if (desc.iconifyId) {
prepareIconifyIconElement(host, desc.iconifyId);
void processIconifyIcons(host);
return;
}
try {
setIcon(host, desc.icon);
} catch {
/* Lucide id may be missing */
}
}
export function decorateCodeBlockTitles(
container: HTMLElement,
fences: Array<{ title?: string }>,
mode: FileTreeIconMode
): void {
const pres = listCodeBlockPres(container);
let preIndex = 0;
for (let fi = 0; fi < fences.length; fi += 1) {
const newTitle = fences[fi].title;
if (preIndex >= pres.length) {
break;
}
const pre = pres[preIndex];
preIndex += 1;
const existing = pre.parentElement?.classList.contains("vp-code-block-title")
? (pre.parentElement as HTMLElement)
: null;
if (existing) {
if (!newTitle) {
existing.replaceWith(pre);
pre.removeAttribute(CODE_TITLE_PROCESSED_ATTR);
continue;
}
if (existing.dataset.title !== newTitle) {
updateWrapperTitle(existing, newTitle, mode);
}
continue;
}
if (!newTitle) {
pre.removeAttribute(CODE_TITLE_PROCESSED_ATTR);
continue;
}
pre.setAttribute(CODE_TITLE_PROCESSED_ATTR, "1");
wrapPreWithTitle(pre, newTitle, mode);
}
for (const wrapper of Array.from(
container.querySelectorAll<HTMLElement>(".vp-code-block-title")
)) {
const title = wrapper.dataset.title;
if (!title) continue;
const pre = wrapper.querySelector("pre");
const label = wrapper.querySelector(".vp-code-block-title-text");
if (!label) continue;
const iconHost = label.querySelector(".vp-code-block-title-icon");
if (!(iconHost instanceof HTMLElement)) continue;
const hasSvg =
iconHost.classList.contains("ft-icon-online") && iconHost.querySelector("svg");
const hasLucide = iconHost.querySelector("svg");
if (hasSvg || hasLucide) continue;
applyCodeTitleIcon(iconHost, title, mode, pre);
}
}
/** Decorate any fenced code with titles inside a rendered subtree (e.g. after nested blocks). */
export function decorateSubtreeCodeFences(
root: HTMLElement,
markdown: string,
mode: FileTreeIconMode
): void {
if (!markdown.trim()) {
return;
}
decorateCodeBlockTitles(root, scanCodeFenceTitles(markdown), mode);
}
function updateWrapperTitle(wrapper: HTMLElement, title: string, mode: FileTreeIconMode): void {
wrapper.dataset.title = title;
const label = wrapper.querySelector(".vp-code-block-title-text");
if (!label) return;
const pre = wrapper.querySelector("pre");
while (label.firstChild) label.removeChild(label.firstChild);
const iconHost = document.createElement("span");
applyCodeTitleIcon(iconHost, title, mode, pre);
label.appendChild(iconHost);
label.appendChild(document.createTextNode(title));
}
function wrapPreWithTitle(pre: HTMLElement, title: string, mode: FileTreeIconMode): void {
const parent = pre.parentElement;
if (!parent) return;
const wrapper = document.createElement("div");
wrapper.className = "vp-code-block-title";
wrapper.dataset.title = title;
const bar = document.createElement("div");
bar.className = "vp-code-block-title-bar";
const label = document.createElement("span");
label.className = "vp-code-block-title-text";
const iconHost = document.createElement("span");
applyCodeTitleIcon(iconHost, title, mode, pre);
label.appendChild(iconHost);
label.appendChild(document.createTextNode(title));
bar.appendChild(label);
parent.insertBefore(wrapper, pre);
wrapper.appendChild(bar);
wrapper.appendChild(pre);
}
import type { App, Component, MarkdownPostProcessorContext } from "obsidian";
import type { PlumeMarkdownContext } from "../markdown/plume-markdown";
import type { CodeTreeFileItem, FileTreeIconMode } from "../types";
export interface BlockRenderContext {
app: App;
sourcePath: string;
component: Component;
postProcessorCtx?: MarkdownPostProcessorContext;
defaultIconMode: FileTreeIconMode;
renderMarkdown?: (container: HTMLElement, markdown: string) => Promise<void>;
/** Resolve a @[code-tree](path) embed into a flat list of CodeTreeFileItem. */
resolveCodeTreeEmbed?: (
sourcePath: string,
dirPath: string
) => Promise<CodeTreeFileItem[] | null>;
/** Plugin settings snapshot for renderers. */
settings?: PlumeRenderSettings;
/** Bumps on each editor change so nested UI (tabs/collapse) can invalidate caches. */
contentEpoch?: number;
}
/** Settings passed into the render pipeline (subset of plugin settings). */
export interface PlumeRenderSettings {
defaultIconMode: FileTreeIconMode;
persistTabSelection: boolean;
collapseLazyBodies: boolean;
tabsLazyPanels: boolean;
debugRender: boolean;
}
export function toPlumeMarkdownContext(ctx: BlockRenderContext): PlumeMarkdownContext {
return {
app: ctx.app,
sourcePath: ctx.sourcePath,
component: ctx.component,
postProcessorCtx: ctx.postProcessorCtx
};
}
export function toBlockRenderContext(
md: PlumeMarkdownContext,
defaultIconMode: FileTreeIconMode,
settings?: PlumeRenderSettings
): BlockRenderContext {
return {
app: md.app,
sourcePath: md.sourcePath,
component: md.component,
postProcessorCtx: md.postProcessorCtx,
defaultIconMode,
settings
};
}
export function triggerPreviewReflow(app: App): void {
window.requestAnimationFrame(() => {
try {
app.workspace.trigger("resize");
} catch {
/* best-effort */
}
});
}
import { describe, expect, it } from "vitest";
import { replaceIconSyntaxInMarkdown } from "./icon-transform";
describe("icon transform", () => {
it("renders VuePress iconify inline syntax", () => {
const html = replaceIconSyntaxInMarkdown("before ::mdi:home:: after");
expect(html).toContain("vp-icon");
expect(html).toContain('data-vp-icon="mdi:home"');
expect(html).toContain("ft-icon-online");
});
it("renders size and color options", () => {
const html = replaceIconSyntaxInMarkdown("::mdi:home =24px /#f00::");
expect(html).toContain("font-size:24px");
expect(html).toContain("color:#f00");
});
it("renders Icon and VPIcon component tags", () => {
const html = replaceIconSyntaxInMarkdown(
'<Icon name="mdi:home" /> <VPIcon provider="iconify" name="mdi:account"></VPIcon>'
);
expect(html).toContain('data-vp-icon="mdi:home"');
expect(html).toContain('data-vp-icon="mdi:account"');
});
it("does not replace inside fenced code blocks", () => {
const md = "```md\n::mdi:home::\n```";
expect(replaceIconSyntaxInMarkdown(md)).toBe(md);
});
});
import { createIconifySpanHtml } from "./iconify-online";
const RE_CODE_FENCE_OPEN = /^(\s*)(`{3,}|~{3,})(.*)$/;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function parseAttrValue(text: string, key: string): string | undefined {
const attrRegex = new RegExp(`${key}=(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
const match = text.match(attrRegex);
if (!match) {
return undefined;
}
return match[1] ?? match[2] ?? match[3] ?? undefined;
}
interface ResolvedInlineIcon {
provider: string;
name: string;
size?: string;
color?: string;
}
function resolveInlineIcon(content: string): ResolvedInlineIcon | null {
let provider = "iconify";
let size: string | undefined;
let color: string | undefined;
const parts = content.trim().split(/\s+/).filter(Boolean);
if (/^(iconify|iconfont|fontawesome)$/i.test(parts[0] ?? "")) {
provider = (parts.shift() ?? provider).toLowerCase();
}
const nameParts: string[] = [];
for (const part of parts) {
if (part.startsWith("=")) {
size = part.slice(1);
} else if (part.startsWith("/")) {
color = part.slice(1);
} else {
nameParts.push(part);
}
}
const cleaned = nameParts.join(" ").trim();
if (provider !== "iconify") {
return null;
}
const name = cleaned.split(/\s+/)[0] ?? "";
if (!name || !name.includes(":")) {
return null;
}
return { provider, name, size, color };
}
function buildIconHtml(icon: ResolvedInlineIcon): string | null {
const style: string[] = [];
if (icon.size) {
const size = escapeHtml(icon.size);
style.push(`font-size:${size}`, `width:${size}`, `height:${size}`);
}
if (icon.color) {
style.push(`color:${escapeHtml(icon.color)}`);
}
return createIconifySpanHtml(icon.name, "vp-icon", style.join(";"));
}
function replaceIconTagsInLine(line: string): string {
return line
.replace(/<(?:Icon|VPIcon)\b([^>]*)\/>/gi, (matched, attrs: string) => {
const provider = parseAttrValue(attrs, "provider") ?? "iconify";
if (provider !== "iconify") {
return matched;
}
const name = parseAttrValue(attrs, "name");
if (!name) {
return matched;
}
const html = buildIconHtml({
provider,
name,
size: parseAttrValue(attrs, "size"),
color: parseAttrValue(attrs, "color")
});
return html ?? matched;
})
.replace(/<(?:Icon|VPIcon)\b([^>]*)>\s*<\/(?:Icon|VPIcon)>/gi, (matched, attrs: string) => {
const provider = parseAttrValue(attrs, "provider") ?? "iconify";
if (provider !== "iconify") {
return matched;
}
const name = parseAttrValue(attrs, "name");
if (!name) {
return matched;
}
const html = buildIconHtml({
provider,
name,
size: parseAttrValue(attrs, "size"),
color: parseAttrValue(attrs, "color")
});
return html ?? matched;
});
}
function replaceInlineIconSyntaxInLine(line: string): string {
let output = "";
let cursor = 0;
while (cursor < line.length) {
const start = line.indexOf("::", cursor);
if (start === -1) {
output += line.slice(cursor);
break;
}
if (line[start + 2] === " " || line[start + 2] === ":") {
output += line.slice(cursor, start + 2);
cursor = start + 2;
continue;
}
const end = line.indexOf("::", start + 2);
if (end === -1 || line[end - 1] === " ") {
output += line.slice(cursor, start + 2);
cursor = start + 2;
continue;
}
const content = line.slice(start + 2, end);
const icon = resolveInlineIcon(content);
const html = icon ? buildIconHtml(icon) : null;
output += line.slice(cursor, start);
output += html ?? line.slice(start, end + 2);
cursor = end + 2;
}
return output;
}
function replaceIconsInLine(line: string): string {
return replaceInlineIconSyntaxInLine(replaceIconTagsInLine(line));
}
export function replaceIconSyntaxInMarkdown(markdown: string): string {
if (!/(::[^:\s][\s\S]*?::|<(?:Icon|VPIcon)\b)/i.test(markdown)) {
return markdown;
}
const lines = markdown.split(/\r?\n/);
const out: string[] = [];
let fenceChar = "";
let fenceLength = 0;
for (const line of lines) {
if (fenceLength > 0) {
out.push(line);
const closeRegex = new RegExp(`^\\s*${fenceChar}{${fenceLength},}\\s*$`);
if (closeRegex.test(line)) {
fenceChar = "";
fenceLength = 0;
}
continue;
}
const fenceMatch = line.match(RE_CODE_FENCE_OPEN);
if (fenceMatch) {
const fence = fenceMatch[2];
fenceChar = fence[0];
fenceLength = fence.length;
out.push(line);
continue;
}
out.push(replaceIconsInLine(line));
}
return out.join("\n");
}
import { normalizeIconifyId } from "../offlineIconify";
const ICONIFY_API_BASE = "https://api.iconify.design";
const iconSvgCache = new Map<string, Promise<string | null>>();
interface IconifyRequestResponse {
status: number;
text: string;
}
type IconifyRequestUrl = (options: {
url: string;
method: "GET";
}) => Promise<IconifyRequestResponse>;
let iconifyRequestUrl: IconifyRequestUrl | null = null;
export function setIconifyRequestUrl(requestUrl: IconifyRequestUrl): void {
iconifyRequestUrl = requestUrl;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function getIconifyUrl(iconId: string): string | null {
const normalized = normalizeIconifyId(iconId);
const separator = normalized.indexOf(":");
if (separator === -1) {
return null;
}
const prefix = normalized.slice(0, separator);
const name = normalized.slice(separator + 1);
if (!prefix || !name) {
return null;
}
return `${ICONIFY_API_BASE}/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`;
}
async function fetchIconifySvg(iconId: string): Promise<string | null> {
const normalized = normalizeIconifyId(iconId);
const cached = iconSvgCache.get(normalized);
if (cached) {
return cached;
}
const pending = (async (): Promise<string | null> => {
const url = getIconifyUrl(normalized);
if (!url) {
return null;
}
if (!iconifyRequestUrl) {
return null;
}
try {
const response = await iconifyRequestUrl({ url, method: "GET" });
if (response.status < 200 || response.status >= 300) {
return null;
}
const svg = response.text;
return /^\s*<svg[\s>]/i.test(svg) ? svg : null;
} catch {
return null;
}
})();
iconSvgCache.set(normalized, pending);
return pending;
}
export function createIconifySpanHtml(
iconId: string,
className = "vp-icon",
style?: string
): string {
const normalized = normalizeIconifyId(iconId);
const styleAttr = style ? ` style="${escapeHtml(style)}"` : "";
return `<span class="${escapeHtml(className)} ft-icon-online" data-vp-icon="${escapeHtml(normalized)}" aria-hidden="true"${styleAttr}></span>`;
}
export function prepareIconifyIconElement(element: HTMLElement, iconId: string): void {
const normalized = normalizeIconifyId(iconId);
element.dataset.vpIcon = normalized;
element.setAttribute("aria-hidden", "true");
element.classList.add("ft-icon-online");
}
function appendSvgMarkup(element: HTMLElement, svg: string): boolean {
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
const svgElement = parsed.documentElement;
if (svgElement.nodeName.toLowerCase() !== "svg") {
return false;
}
element.appendChild(element.ownerDocument.importNode(svgElement, true));
return true;
}
export async function processIconifyIcons(rootElement: HTMLElement): Promise<void> {
const elements = [
...(rootElement.matches("[data-vp-icon]") ? [rootElement] : []),
...Array.from(
rootElement.querySelectorAll<HTMLElement>("[data-vp-icon]")
)
];
await Promise.all(elements.map(async (element) => {
const iconId = element.dataset.vpIcon;
if (!iconId || element.dataset.vpIconLoaded === "1" || element.dataset.vpIconLoading === "1") {
return;
}
element.dataset.vpIconLoading = "1";
const svg = await fetchIconifySvg(iconId);
delete element.dataset.vpIconLoading;
if (!element.isConnected && !rootElement.contains(element)) {
return;
}
if (!svg) {
element.dataset.vpIconFailed = "1";
return;
}
element.empty();
if (!appendSvgMarkup(element, svg)) {
element.dataset.vpIconFailed = "1";
return;
}
element.dataset.vpIconLoaded = "1";
delete element.dataset.vpIconFailed;
}));
}
/** Public render pipeline surface (barrel). */
export type { BlockRenderContext, PlumeRenderSettings } from "./context";
export {
renderInnerMarkdown,
renderNestedMarkdownContent,
renderPlumeBlocksInto
} from "./pipeline";
export {
scanCodeFenceTitles,
scanCodeFences,
decorateCodeBlockTitles,
decorateSubtreeCodeFences
} from "./code-fence";
export { gatherMasonryItems } from "../render";
export { renderTabbedContainer } from "./tabbed-container";
export { renderCollapseBlock } from "./blocks/collapse";
import type { BlockRenderContext } from "./context";
import { renderInnerMarkdown } from "./pipeline";
const INLINE_MD_BLOCK_TAGS = new Set([
"P",
"DIV",
"UL",
"OL",
"LI",
"PRE",
"BLOCKQUOTE",
"TABLE",
"HR",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6"
]);
function appendPhrasingFromRendered(host: HTMLElement, node: Node): void {
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent ?? "";
if (t) {
host.appendChild(document.createTextNode(t));
}
return;
}
if (!(node instanceof HTMLElement)) {
return;
}
if (node.tagName === "P" || INLINE_MD_BLOCK_TAGS.has(node.tagName)) {
for (const child of Array.from(node.childNodes)) {
appendPhrasingFromRendered(host, child);
}
return;
}
host.appendChild(node.cloneNode(true));
}
export async function renderInlineMarkdownInto(
host: HTMLElement,
text: string,
ctx: BlockRenderContext,
options?: { phrasingOnly?: boolean }
): Promise<void> {
const temp = document.createElement("div");
await renderInnerMarkdown(temp, text, ctx);
const phrasingHost =
options?.phrasingOnly === true
|| host.tagName === "P"
|| host.tagName === "SPAN";
host.empty();
if (phrasingHost) {
for (const node of Array.from(temp.childNodes)) {
appendPhrasingFromRendered(host, node);
}
return;
}
const elementChildren = Array.from(temp.children).filter(
(node): node is HTMLElement => node instanceof HTMLElement
);
if (elementChildren.length === 1 && elementChildren[0].tagName === "P") {
const paragraph = elementChildren[0];
while (paragraph.firstChild) {
host.appendChild(paragraph.firstChild);
}
return;
}
while (temp.firstChild) {
host.appendChild(temp.firstChild);
}
}
import { replaceBadgeTagsInMarkdown } from "./badge-transform";
import { replaceIconSyntaxInMarkdown } from "./icon-transform";
export function applyVuepressMarkdownTransforms(markdown: string): string {
return replaceIconSyntaxInMarkdown(replaceBadgeTagsInMarkdown(markdown));
}
import { parseAllBlocks, dedentStepBody } from "../parser";
import { renderPlumeMarkdown } from "../markdown/plume-markdown";
import type { ParsedBlock } from "../types";
import { invokeBlockRenderer } from "./block-registry";
import {
decorateCodeBlockTitles,
decorateSubtreeCodeFences,
scanCodeFenceTitles
} from "./code-fence";
import {
type BlockRenderContext,
toPlumeMarkdownContext
} from "./context";
export const BLOCK_PLACEHOLDER_CLASS = "vp-block-placeholder";
export const BLOCK_PLACEHOLDER_ATTR = "data-vp-block-id";
export function contentIsOnlyBlocksAndBlankLines(
content: string,
blocks: ParsedBlock[]
): boolean {
if (blocks.length === 0) {
return false;
}
const lines = content.split(/\r?\n/);
const inBlock = (line: number): boolean =>
blocks.some((b) => line >= b.startLine && line <= b.endLine);
for (let i = 0; i < lines.length; i += 1) {
if (!lines[i].trim()) {
continue;
}
if (inBlock(i)) {
continue;
}
return false;
}
return true;
}
export function pruneEmptyMarkdownNodes(root: HTMLElement): void {
for (const p of Array.from(root.querySelectorAll("p"))) {
if (p.closest(".vp-card-wrapper, .vp-file-tree, .vp-card-masonry, .vp-card-grid")) {
continue;
}
const text = p.textContent?.replace(/\u00a0/g, "").trim() ?? "";
if (text) {
continue;
}
if (p.querySelector("img, pre, code, table, ul, ol, blockquote, .vp-block-placeholder")) {
continue;
}
p.remove();
}
}
export async function renderPlumeBlocksInto(
container: HTMLElement,
blocks: ParsedBlock[],
ctx: BlockRenderContext
): Promise<void> {
for (const block of blocks) {
const host = document.createElement("div");
// Must be in the document before render: Obsidian setIcon() only paints on connected nodes.
container.appendChild(host);
try {
await invokeBlockRenderer(host, block, ctx);
} catch (err) {
console.error("[theme-plume] block render failed", err);
host.textContent = block.rawContent;
}
while (host.firstChild) {
container.insertBefore(host.firstChild, host);
}
host.remove();
}
}
async function renderMarkdownInto(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext
): Promise<void> {
if (ctx.renderMarkdown) {
await ctx.renderMarkdown(container, markdown);
return;
}
await renderPlumeMarkdown(container, markdown, toPlumeMarkdownContext(ctx));
}
export async function renderNestedMarkdownContent(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext,
options?: { dedent?: boolean }
): Promise<void> {
let content = markdown.replace(/^\n+|\n+$/g, "");
if (!content) {
return;
}
if (options?.dedent) {
content = dedentStepBody(content);
}
const blocks = parseAllBlocks(content, ctx.defaultIconMode);
if (blocks.length === 1) {
await invokeBlockRenderer(container, blocks[0], ctx);
pruneEmptyMarkdownNodes(container);
decorateSubtreeCodeFences(container, content, ctx.defaultIconMode);
return;
}
if (blocks.length > 0 && contentIsOnlyBlocksAndBlankLines(content, blocks)) {
await renderPlumeBlocksInto(container, blocks, ctx);
pruneEmptyMarkdownNodes(container);
return;
}
await renderInnerMarkdown(container, content, ctx);
}
export async function renderInnerMarkdown(
container: HTMLElement,
markdown: string,
ctx: BlockRenderContext
): Promise<void> {
const source = markdown.replace(/^\n+|\n+$/g, "");
if (!source) {
return;
}
const blocks = parseAllBlocks(source, ctx.defaultIconMode);
if (blocks.length === 0) {
await renderMarkdownInto(container, markdown, ctx);
decorateCodeBlockTitles(container, scanCodeFenceTitles(markdown), ctx.defaultIconMode);
pruneEmptyMarkdownNodes(container);
return;
}
const lines = source.split(/\r?\n/);
const placeholderById = new Map<string, ParsedBlock>();
const out: string[] = [];
let cursor = 0;
for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i];
for (let k = cursor; k < block.startLine; k += 1) {
out.push(lines[k]);
}
const opener = lines[block.startLine] ?? "";
const indentMatch = opener.match(/^[\t ]*/);
const indent = indentMatch?.[0] ?? "";
const id = `vp-blk-${i + 1}-${Math.random().toString(36).slice(2, 8)}`;
placeholderById.set(id, block);
if (out.length > 0 && out[out.length - 1].trim() !== "") {
out.push("");
}
out.push(
`${indent}<div class="${BLOCK_PLACEHOLDER_CLASS}" ${BLOCK_PLACEHOLDER_ATTR}="${id}"></div>`
);
cursor = block.endLine + 1;
if (cursor < lines.length && lines[cursor].trim() === "") {
out.push("");
cursor += 1;
}
}
for (let k = cursor; k < lines.length; k += 1) {
out.push(lines[k]);
}
container.empty();
const renderedMarkdown = out.join("\n");
await renderMarkdownInto(container, renderedMarkdown, ctx);
decorateCodeBlockTitles(container, scanCodeFenceTitles(renderedMarkdown), ctx.defaultIconMode);
const placeholders = Array.from(
container.querySelectorAll(`.${BLOCK_PLACEHOLDER_CLASS}[${BLOCK_PLACEHOLDER_ATTR}]`)
);
for (const node of placeholders) {
if (!(node instanceof HTMLElement)) continue;
const id = node.getAttribute(BLOCK_PLACEHOLDER_ATTR);
if (!id) continue;
const block = placeholderById.get(id);
if (!block) continue;
node.removeAttribute(BLOCK_PLACEHOLDER_ATTR);
node.classList.remove(BLOCK_PLACEHOLDER_CLASS);
node.empty();
try {
await invokeBlockRenderer(node, block, ctx);
decorateSubtreeCodeFences(node, block.rawContent, ctx.defaultIconMode);
} catch (err) {
console.error("[theme-plume] block render failed", err);
if (ctx.settings?.debugRender) {
node.createEl("p", { cls: "plume-render-error", text: `Failed: ${block.type}` });
}
node.textContent = block.rawContent;
}
}
pruneEmptyMarkdownNodes(container);
}
/** Shared tab persistence + cross-instance sync for tabs / code-tabs. */
export const TABS_SYNC_EVENT = "vpft:tabs-sync";
export const SHARED_TAB_ACTIVE = new Map<string, string>();
const TAB_STORE_KEY = "vp-plume-tab-store";
function readTabStore(): Record<string, string> {
try {
const raw = window.localStorage.getItem(TAB_STORE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? (parsed as Record<string, string>) : {};
} catch {
return {};
}
}
export function writeTabStoreValue(id: string, value: string): void {
try {
const store = readTabStore();
if (store[id] === value) return;
store[id] = value;
window.localStorage.setItem(TAB_STORE_KEY, JSON.stringify(store));
} catch {
/* localStorage may be unavailable */
}
}
export function getTabStoreValue(id: string): string | undefined {
return readTabStore()[id];
}
export function attachTabsKeyboardNav(
nav: HTMLElement,
buttons: Map<string, HTMLButtonElement>,
getActive: () => string,
activate: (value: string) => void
): void {
nav.addEventListener("keydown", (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (!target || target.getAttribute("role") !== "tab") return;
const values = Array.from(buttons.keys());
if (values.length === 0) return;
const current = values.indexOf(getActive());
const idx = current === -1 ? 0 : current;
let next = -1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = (idx + 1) % values.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (idx - 1 + values.length) % values.length;
break;
case "Home":
next = 0;
break;
case "End":
next = values.length - 1;
break;
default:
return;
}
event.preventDefault();
const nextValue = values[next];
activate(nextValue);
buttons.get(nextValue)?.focus();
});
}
import { setIcon } from "obsidian";
import { resolveNodeIcon } from "../icons";
import type { FileTreeIconMode, TabItem } from "../types";
import { hashString } from "../utils/hash";
import { prepareIconifyIconElement, processIconifyIcons } from "./iconify-online";
import {
attachTabsKeyboardNav,
getTabStoreValue,
SHARED_TAB_ACTIVE,
TABS_SYNC_EVENT,
writeTabStoreValue
} from "./tab-store";
export type TabbedVariant = "tabs" | "code-tabs";
export interface TabbedContainerOptions {
variant: TabbedVariant;
tabs: TabItem[];
sharedId?: string;
defaultIconMode: FileTreeIconMode;
/** When false, skip localStorage tab persistence. Default true. */
persistSelection?: boolean;
/** When true (default), render panel markdown only when the tab becomes active. */
lazyPanels?: boolean;
/** Bumps when the file is edited; forces panel re-render even if DOM nodes are reused. */
contentEpoch?: number;
renderPanel: (panel: HTMLElement, markdown: string) => Promise<void>;
}
function tabsContentRevision(tabs: TabItem[]): string {
return hashString(tabs.map((t) => `${t.value}\0${t.title}\0${t.content}`).join("\n"));
}
const VARIANT_CLASSES: Record<
TabbedVariant,
{ wrapper: string; nav: string; body: string; button: string; panel: string; btnPrefix: string; panelPrefix: string }
> = {
tabs: {
wrapper: "vp-tabs obsidian-vuepress-tabs",
nav: "vp-tabs-nav",
body: "vp-tabs-body",
button: "vp-tabs-tab",
panel: "vp-tabs-panel",
btnPrefix: "vp-tabs-btn",
panelPrefix: "vp-tabs-panel"
},
"code-tabs": {
wrapper: "vp-code-tabs obsidian-vuepress-code-tabs",
nav: "vp-code-tabs-nav",
body: "vp-code-tabs-body",
button: "vp-code-tab-nav",
panel: "vp-code-tab",
btnPrefix: "vp-code-tabs-btn",
panelPrefix: "vp-code-tabs-panel"
}
};
function decorateCodeTabButton(
button: HTMLButtonElement,
tab: TabItem,
mode: FileTreeIconMode
): void {
const iconHost = document.createElement("span");
iconHost.className = "vp-code-tab-icon ft-icon";
const desc = resolveNodeIcon(tab.title, "file", false, mode);
if (desc.colorClass) iconHost.classList.add(desc.colorClass);
if (desc.iconifyId) {
prepareIconifyIconElement(iconHost, desc.iconifyId);
void processIconifyIcons(iconHost);
} else {
setIcon(iconHost, desc.icon);
}
button.appendChild(iconHost);
const label = document.createElement("span");
label.className = "vp-code-tab-label";
label.textContent = tab.title;
button.appendChild(label);
}
/**
* Shared tabs / code-tabs renderer (nav, persistence, sync, panel markdown).
*/
export async function renderTabbedContainer(
container: HTMLElement,
options: TabbedContainerOptions
): Promise<void> {
const { variant, tabs, defaultIconMode, renderPanel } = options;
const persistSelection = options.persistSelection !== false;
const lazyPanels = options.lazyPanels !== false;
const cls = VARIANT_CLASSES[variant];
const sharedId = options.sharedId?.trim();
const tabByValue = new Map(tabs.map((t) => [t.value, t]));
const contentRevision = tabsContentRevision(tabs);
const contentEpoch = String(options.contentEpoch ?? 0);
const wrapper = document.createElement("div");
wrapper.className = cls.wrapper;
wrapper.dataset.plumeTabsRevision = contentRevision;
wrapper.dataset.plumeContentEpoch = contentEpoch;
container.appendChild(wrapper);
const nav = document.createElement("div");
nav.className = cls.nav;
nav.setAttribute("role", "tablist");
nav.setAttribute("aria-label", variant === "code-tabs" ? "代码选项卡" : "标签页");
wrapper.appendChild(nav);
const body = document.createElement("div");
body.className = cls.body;
wrapper.appendChild(body);
const explicitActive = tabs.find((t) => t.active)?.value;
const sharedActive = sharedId ? SHARED_TAB_ACTIVE.get(sharedId) : undefined;
const persistedActive =
sharedId && persistSelection ? getTabStoreValue(sharedId) : undefined;
const initialValue =
(sharedActive && tabs.some((t) => t.value === sharedActive) ? sharedActive : undefined)
?? (persistedActive && tabs.some((t) => t.value === persistedActive) ? persistedActive : undefined)
?? explicitActive
?? tabs[0]?.value;
const buttons = new Map<string, HTMLButtonElement>();
const panels = new Map<string, HTMLElement>();
let activeValue = initialValue ?? "";
let panelRenderSeq = 0;
const markPanelRendered = (panel: HTMLElement, value: string): void => {
panel.dataset.plumeTabRenderedRev = `${value}:${contentRevision}:${contentEpoch}`;
};
const isPanelRendered = (panel: HTMLElement, value: string): boolean =>
panel.dataset.plumeTabRenderedRev === `${value}:${contentRevision}:${contentEpoch}`
&& panel.childElementCount > 0
&& wrapper.dataset.plumeContentEpoch === contentEpoch;
const ensurePanelRendered = async (value: string): Promise<void> => {
const panel = panels.get(value);
const tab = tabByValue.get(value);
if (!(panel instanceof HTMLElement) || !tab) {
return;
}
if (!lazyPanels || isPanelRendered(panel, value)) {
return;
}
const token = String(++panelRenderSeq);
panel.dataset.plumeTabRenderToken = token;
panel.empty();
try {
await renderPanel(panel, tab.content);
if (
panel.dataset.plumeTabRenderToken !== token
|| !panel.isConnected
|| wrapper.dataset.plumeTabsRevision !== contentRevision
|| wrapper.dataset.plumeContentEpoch !== contentEpoch
) {
return;
}
markPanelRendered(panel, value);
} catch {
if (
panel.dataset.plumeTabRenderToken !== token
|| !panel.isConnected
|| wrapper.dataset.plumeTabsRevision !== contentRevision
|| wrapper.dataset.plumeContentEpoch !== contentEpoch
) {
return;
}
panel.empty();
panel.textContent = tab.content;
markPanelRendered(panel, value);
}
};
const setActive = (value: string, emit: boolean): void => {
if (!buttons.has(value)) {
return;
}
activeValue = value;
for (const [v, btn] of buttons) {
const isActive = v === value;
btn.classList.toggle("active", isActive);
btn.setAttribute("aria-selected", isActive ? "true" : "false");
btn.tabIndex = isActive ? 0 : -1;
btn.setAttribute("aria-disabled", isActive ? "true" : "false");
}
for (const [v, panel] of panels) {
const isActive = v === value;
if (isActive) {
panel.classList.add("active");
// 切换时总是刷新内容
panel.empty();
const tab = tabByValue.get(v);
if (tab) {
void renderPanel(panel, tab.content);
}
// 动画:先透明,后淡入
} else {
panel.classList.remove("active");
}
panel.setAttribute("aria-hidden", isActive ? "false" : "true");
panel.setAttribute("aria-expanded", isActive ? "true" : "false");
}
if (lazyPanels) {
void ensurePanelRendered(value);
}
if (sharedId) {
SHARED_TAB_ACTIVE.set(sharedId, value);
if (persistSelection) {
writeTabStoreValue(sharedId, value);
}
if (emit) {
document.dispatchEvent(
new CustomEvent(TABS_SYNC_EVENT, {
detail: { id: sharedId, value, source: wrapper }
})
);
}
}
};
for (const tab of tabs) {
const buttonId = `${cls.btnPrefix}-${Math.random().toString(36).slice(2, 10)}`;
const panelId = `${cls.panelPrefix}-${Math.random().toString(36).slice(2, 10)}`;
const button = document.createElement("button");
button.type = "button";
button.className = cls.button;
button.setAttribute("role", "tab");
button.id = buttonId;
button.setAttribute("aria-controls", panelId);
button.setAttribute("tabindex", "-1"); // 默认非激活
if (variant === "code-tabs") {
decorateCodeTabButton(button, tab, defaultIconMode);
} else {
button.textContent = tab.title;
}
nav.appendChild(button);
buttons.set(tab.value, button);
const panel = document.createElement("section");
panel.className = cls.panel;
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", buttonId);
panel.setAttribute("tabindex", "0");
body.appendChild(panel);
panels.set(tab.value, panel);
button.addEventListener("click", () => {
if (activeValue === tab.value) return;
setActive(tab.value, true);
});
}
if (sharedId) {
const onSync = (event: Event): void => {
if (!wrapper.isConnected) {
document.removeEventListener(TABS_SYNC_EVENT, onSync as EventListener);
return;
}
const detail = (event as CustomEvent<{ id?: string; value?: string; source?: HTMLElement }>)
.detail;
if (!detail || detail.id !== sharedId || detail.source === wrapper) return;
if (!detail.value || detail.value === activeValue || !buttons.has(detail.value)) return;
setActive(detail.value, false);
};
document.addEventListener(TABS_SYNC_EVENT, onSync as EventListener);
}
attachTabsKeyboardNav(nav, buttons, () => activeValue, (value) => setActive(value, true));
if (initialValue) {
setActive(initialValue, false);
}
if (lazyPanels) {
if (initialValue) {
await ensurePanelRendered(initialValue);
}
return;
}
await Promise.all(
tabs.map(async (tab) => {
const panel = panels.get(tab.value);
if (!panel) return;
try {
await renderPanel(panel, tab.content);
markPanelRendered(panel, tab.value);
} catch {
panel.empty();
panel.textContent = tab.content;
markPanelRendered(panel, tab.value);
}
})
);
}
export type FileTreeIconMode = "simple" | "colored";
export interface FileTreeNodeProps {
filename: string;
filepath?: string;
comment?: string;
focus?: boolean;
expanded?: boolean;
type: "folder" | "file";
diff?: "add" | "remove";
level?: number;
}
export interface FileTreeNode extends FileTreeNodeProps {
level: number;
children: FileTreeNode[];
}
export interface FileTreeContainerAttrs {
title?: string;
icon?: FileTreeIconMode;
}
export interface CodeTreeContainerAttrs extends FileTreeContainerAttrs {
height?: string;
entry?: string;
}
export interface CodeTreeFileItem {
filepath: string;
language: string;
content: string;
active?: boolean;
}
export interface TabsContainerAttrs {
id?: string;
}
export interface CodeTabsContainerAttrs {
id?: string;
}
export interface TabItem {
title: string;
value: string;
content: string;
active?: boolean;
}
export type PromptContainerType = "note" | "info" | "tip" | "warning" | "caution" | "details" | "important";
export interface PromptContainerAttrs {
type: PromptContainerType;
title?: string;
}
export interface CardContainerAttrs {
title?: string;
icon?: string;
}
export interface CardGridContainerAttrs {
cols?: string;
}
export interface CardMasonryContainerAttrs {
cols?: string;
gap?: string;
}
export interface RepoCardContainerAttrs {
repo: string;
provider?: "github" | "gitee";
fullname?: boolean;
}
export interface LinkCardContainerAttrs {
href: string;
title?: string;
icon?: string;
description?: string;
target?: string;
rel?: string;
}
export interface ImageCardContainerAttrs {
image: string;
title?: string;
description?: string;
href?: string;
author?: string;
date?: string;
width?: string;
center?: boolean;
}
export interface FieldContainerAttrs {
name: string;
type?: string;
required?: boolean;
optional?: boolean;
deprecated?: boolean;
default?: string;
}
// field-group is a structural wrapper; no attributes.
export type FieldGroupContainerAttrs = Record<string, never>;
export interface FlexContainerAttrs {
align?: "start" | "end" | "center";
justify?: "between" | "around" | "center";
column?: boolean;
wrap?: boolean;
gap?: string;
}
export type AlignContainerType = "left" | "center" | "right";
export interface AlignContainerAttrs {
align: AlignContainerType;
}
export interface WindowContainerAttrs {
title?: string;
height?: string;
gap?: string;
noPadding?: boolean;
}
export interface ChatContainerAttrs {
title?: string;
}
export interface RepoCardInfo {
name: string;
fullName: string;
description: string;
url: string;
stars: number;
forks: number;
language: string;
languageColor: string;
archived: boolean;
visibility: "Private" | "Public";
template: boolean;
ownerType: "User" | "Organization";
license: { name: string; url?: string } | null;
}
export interface CollapseContainerAttrs {
accordion?: boolean;
expand?: boolean;
}
export type TimelinePlacement = "left" | "right" | "between";
export type TimelineLineStyle = "solid" | "dashed" | "dotted";
export type TimelineItemType =
| "info"
| "tip"
| "success"
| "warning"
| "danger"
| "caution"
| "important";
export interface TimelineContainerAttrs {
horizontal?: boolean;
card?: boolean;
placement?: TimelinePlacement;
line?: TimelineLineStyle;
}
export interface TimelineItemMeta {
time?: string;
type?: string;
icon?: string;
color?: string;
line?: TimelineLineStyle;
card?: boolean;
placement?: "left" | "right";
}
export interface FileTreePluginSettings {
defaultIconMode: FileTreeIconMode;
/** Remember selected tab per `::: tabs#id` / `::: code-tabs#id` in localStorage. */
persistTabSelection: boolean;
/** Defer collapse panel body render until the panel is opened. */
collapseLazyBodies: boolean;
/** Render only the active tab panel; others load on first switch. */
tabsLazyPanels: boolean;
/** Log render failures and show debug hints in preview. */
debugRender: boolean;
}
export const DEFAULT_SETTINGS: FileTreePluginSettings = {
defaultIconMode: "colored",
persistTabSelection: true,
collapseLazyBodies: true,
tabsLazyPanels: true,
debugRender: false
};
export type BlockType =
| "file-tree"
| "code-tree"
| "code-tree-embed"
| "tabs"
| "code-tabs"
| "steps"
| "prompt"
| "collapse"
| "card"
| "card-grid"
| "card-masonry"
| "repo-card"
| "link-card"
| "image-card"
| "field"
| "field-group"
| "flex"
| "window"
| "chat"
| "timeline"
| "align";
export interface ParsedBlock {
type: BlockType;
/** 0-based, inclusive */
startLine: number;
/** 0-based, inclusive */
endLine: number;
/** raw content lines joined by `\n`, NOT including the open/close markers */
rawContent: string;
/** marker length for `:::` (3+) blocks; 0 for embed */
markerLen: number;
attrs:
| FileTreeContainerAttrs
| CodeTreeContainerAttrs
| TabsContainerAttrs
| CodeTabsContainerAttrs
| PromptContainerAttrs
| CollapseContainerAttrs
| CardContainerAttrs
| CardGridContainerAttrs
| CardMasonryContainerAttrs
| RepoCardContainerAttrs
| LinkCardContainerAttrs
| ImageCardContainerAttrs
| FieldContainerAttrs
| FieldGroupContainerAttrs
| FlexContainerAttrs
| AlignContainerAttrs
| WindowContainerAttrs
| ChatContainerAttrs
| TimelineContainerAttrs
| { dirPath: string } & CodeTreeContainerAttrs
| Record<string, never>;
}
/** Fast non-crypto string hash for cache / revision keys. */
export function hashString(input: string): string {
let h = 2166136261;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(36);
}
路径相对于本文件;
build:demo会跳过超大文件与main.js等,避免静态站构建占满内存。
Markdown 源码
:::: field-group
::: field name="theme" type="ThemeConfig" required default="{ base: '/' }"
主题配置
:::
::: field name="enabled" type="boolean" optional default="true"
是否启用
:::
::: field name="callback" type="(...args: any[]) => void" optional default="() => {}"
`badge:tip:v1.0.0 新增`
回调函数
:::
::: field name="other" type="string" deprecated
`badge:danger:v0.9.0 弃用`
已弃用属性
:::
::::6. 字段 field / field-group
{ base: '/' }
主题配置
true
是否启用
() => {}
badge:tip:v1.0.0 新增
回调函数
badge:danger:v0.9.0 弃用
已弃用属性
Markdown 源码
::: tabs
@tab npm
npm 应该与 Node.js 被一同安装。
@tab pnpm
```sh
corepack enable
corepack use pnpm@8
```
:::
> 支持 `::: tabs#id` / `id="..."` 与 localStorage 记忆选中项(见插件设置)。7. 选项卡 ::: tabs
npm 应该与 Node.js 被一同安装。
支持
::: tabs#id/id="..."与 localStorage 记忆选中项(见插件设置)。
Markdown 源码
::: code-tabs
@tab config.js
```js
/**
* @type {import('vuepress').UserConfig}
*/
const config = {
// ..
}
export default config
```
@tab config.ts
```ts
import type { UserConfig } from 'vuepress'
const config: UserConfig = {
// ..
}
export default config
```
:::8. 代码选项卡 ::: code-tabs
/**
* @type {import('vuepress').UserConfig}
*/
const config = {
// ..
}
export default configMarkdown 源码
::: timeline
- 节点一
time=2025-03-20 type=success
正文内容
- 节点二
time=2025-02-21 type=warning
正文内容
- 节点三
time=2025-01-22 type=danger
正文内容
:::
::: timeline horizontal
- 节点一
time=2025-03-20
正文内容
- 节点二
time=2025-04-20 type=success
正文内容
- 节点三
time=2025-01-22 type=danger
正文内容
- 节点四
time=2025-01-22 type=important
正文内容
:::
::: timeline placement="right"
- 节点一
time=2025-03-20
正文内容
- 节点二
time=2025-04-20 type=success
正文内容
- 节点三
time=2025-01-22 type=danger
正文内容
- 节点四
time=2025-01-22 type=important
正文内容
:::
::: timeline placement="between"
- 节点一
time=2025-03-20 placement=right
正文内容
- 节点二
time=2025-04-20 type=success
正文内容
- 节点三
time=2025-01-22 type=danger placement=right
正文内容
- 节点四
time=2025-01-22 type=important
正文内容
:::
::: timeline line="dotted"
- 节点一
time=2025-03-20
正文内容
- 节点二
time=2025-04-20 type=success
正文内容
- 节点三
time=2025-01-22 type=danger line=dashed
正文内容
- 节点四
time=2025-01-22 type=important line=solid
正文内容
:::9. 时间线 ::: timeline
节点一
正文内容
2025-03-20
节点二
正文内容
2025-02-21
节点三
正文内容
2025-01-22
节点一
正文内容
2025-03-20
节点二
正文内容
2025-04-20
节点三
正文内容
2025-01-22
节点四
正文内容
2025-01-22
节点一
正文内容
2025-03-20
节点二
正文内容
2025-04-20
节点三
正文内容
2025-01-22
节点四
正文内容
2025-01-22
节点一
正文内容
2025-03-20
节点二
正文内容
2025-04-20
节点三
正文内容
2025-01-22
节点四
正文内容
2025-01-22
节点一
正文内容
2025-03-20
节点二
正文内容
2025-04-20
节点三
正文内容
2025-01-22
节点四
正文内容
2025-01-22
Markdown 源码
::: flex between center
| 列 1 | 列 2 | 列 3 |
| ---- | ---- | ---- |
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 列 1 | 列 2 | 列 3 |
| ---- | ---- | ---- |
| 1 | 2 | 3 |
| 4 | 5 | 6 |
:::10. 弹性布局 ::: flex
| 列 1 | 列 2 | 列 3 |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 列 1 | 列 2 | 列 3 |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |
Markdown 源码
::: collapse expand
- 标题 1
正文内容
- :- 标题 2
正文内容
- 标题 3
正文内容
:::
> `:-` 前缀表示默认展开;`accordion` 属性可改为手风琴模式。11. 折叠 ::: collapse
标题 1
正文内容
标题 2
正文内容
标题 3
正文内容
:-前缀表示默认展开;accordion属性可改为手风琴模式。
Markdown 源码
::: chat title="标题"
{:2025-03-24 10:15:00}
{用户一}
用户一的消息
{.}
本人的消息
{用户二}
用户二的消息
{.}
本人的消息
:::12. 对话 ::: chat
标题
Markdown 源码
在围栏 info 中写 `title="..."`:
```py title="test.py"
import numpy as np
```
```ts title="example.ts"
const answer = 42
```13. 代码块标题 title
在围栏 info 中写 title="...":
import numpy as npconst answer = 42Markdown 源码
`badge:tip:已完成`
`badge:info:测试中`
`badge:warning:开发中`
`badge:danger:已废弃`
(语法:反引号包裹 `` `badge:类型:文本` ``,类型与文本也可用 `|` 分隔。)14. 行内徽章 badge
badge:tip:已完成
badge:info:测试中
badge:warning:开发中
badge:danger:已废弃
(语法:反引号包裹 `badge:类型:文本`,类型与文本也可用 | 分隔。)
Markdown 源码
> **`icon`(Obsidian)**:使用内置 **Lucide 图标名**(如 `smile`、`sparkles`、`external-link`),或图片 URL。不支持 `twemoji:`;已打包的 Iconify 可用 `logos:github-icon` 等。
### 单个卡片
::: card title="标题" icon="smile"
这里是卡片内容。
:::
### 多个卡片
:::: card-grid
::: card title="卡片标题 1" icon="smile"
这里是卡片内容。
:::
::: card title="卡片标题 2" icon="sparkles"
这里是卡片内容。
:::
::::15. 卡片 ::: card / card-grid
icon(Obsidian):使用内置 Lucide 图标名(如smile、sparkles、external-link),或图片 URL。不支持twemoji:;已打包的 Iconify 可用logos:github-icon等。
单个卡片
多个卡片
这里是卡片内容。
这里是卡片内容。
Markdown 源码
::: link-card href="https://obsidian.md" title="Obsidian 官网" icon="external-link" description="个人知识库的瑞士军刀"
:::
`href` 也可裸写位置参数:
::: link-card https://github.com title="GitHub" icon="github"
:::
`description` 写在 body(支持 Markdown),优先级低于 `description=` 属性:
::: link-card href="我的笔记" title="跳到笔记" icon="file-text"
这是一段**多行**描述,可以写 markdown。
:::16. 链接卡片 ::: link-card
href 也可裸写位置参数:
description 写在 body(支持 Markdown),优先级低于 description= 属性:
Markdown 源码
::: image-card image="https://picsum.photos/id/1015/600/400" title="星空" author="John" date="2025-06-01" width="600" center
:::17. 图片卡片 ::: image-card
星空
John | Jun 1, 2025
Markdown 源码
### 卡片瀑布
:::: card-masonry
::: card title="卡片1"
卡片内容
:::
::: card title="卡片2"
卡片内容
卡片内容
:::
::: card title="卡片3"
卡片内容
:::
::: card title="卡片4"
卡片内容
:::
::: card title="卡片5"
卡片内容
卡片内容
:::
::: card title="卡片6"
卡片内容
:::
::::
### 代码块瀑布
::: card-masonry
```ts
const a = 1
```
```json
{
"name": "John"
}
```
```css
p {
color: red;
}
```
```html
<html>
<body>
<h1>Hello world</h1>
</body>
</html>
```
```ts
const a = 12
const b = 1
```
```rust
fn main() {
println!("Hello, world!");
}
```
:::
### 图片瀑布
::: card-masonry cols=3
::: image-card image="https://picsum.photos/id/1015/600/400" title="山涧溪流" author="Unsplash" date="2024-03-12"
清晨的山谷里,溪水从石缝中流出,带着冷冽的雾气。
:::
::: image-card image="https://picsum.photos/id/1025/600/700" title="小猴沉思" author="Picsum" date="2023-11-04"
:::
::: image-card image="https://picsum.photos/id/1043/600/500" title="桥与晨雾" author="Anonymous" date="2024-01-20" href="https://picsum.photos/id/1043"
雾气漫过老桥,远处的灯还没熄。
:::
::: image-card image="https://picsum.photos/id/1059/600/800" title="林间小路" author="Unsplash"
落叶铺满整条小路,没有尽头。
:::
::: image-card image="https://picsum.photos/id/106/600/400" title="花田" date="2024-05-08"
:::
::: image-card image="https://picsum.photos/id/1074/600/600" title="雪原" author="Photographer" date="2025-12-25"
极北的雪,安静得能听见自己的呼吸。
:::
::: image-card image="https://picsum.photos/id/110/600/900" title="峡谷俯瞰" author="John Doe" date="2024-08-15"
站在悬崖边,风把所有声音都带走了。
:::
::: image-card image="https://picsum.photos/id/1084/600/450" title="海岸黄昏"
:::
::: image-card image="https://picsum.photos/id/1080/600/600" title="樱桃" author="Studio" date="2025-04-01" href="https://picsum.photos"
盘子里的几颗樱桃,红得发亮。
:::
:::18. 瀑布流 ::: card-masonry
卡片瀑布
卡片内容
卡片内容
卡片内容
卡片内容
卡片内容
卡片内容
卡片内容
卡片内容
代码块瀑布
const a = 1p {
color: red;
}const a = 12
const b = 1{
"name": "John"
}<html>
<body>
<h1>Hello world</h1>
</body>
</html>fn main() {
println!("Hello, world!");
}图片瀑布
Markdown 源码
需联网请求 GitHub / Gitee API(`示例.md` 未收录,Obsidian 扩展):
::: repo-card repo="pengzhanbo/vuepress-theme-plume" provider="github"
:::19. 仓库卡片 ::: repo-card
需联网请求 GitHub / Gitee API(示例.md 未收录,Obsidian 扩展):
Markdown 源码
::: window title="终端" height="200"
```bash title="build.sh"
npm run build
```
:::20. 窗口 ::: window
终端
npm run build