Compare commits

...

64 Commits

Author SHA1 Message Date
pengzhanbo
6e2d2b3dc1 docs: update demos description 2026-04-29 04:31:04 +08:00
mcenahle
475d7f2db1
docs: update site URL from d.mcenahle.cn to d.mcenahle.com (#899) 2026-04-27 13:47:51 +08:00
pengzhanbo
5d5b5399ff build: publish v1.0.0-rc.198 2026-04-26 14:51:52 +08:00
pengzhanbo
26c588ab23 fix(theme): fix plugin-hint default options 2026-04-26 14:50:14 +08:00
pengzhanbo
a9e7ebd6ba build: publish v1.0.0-rc.197 2026-04-26 14:35:32 +08:00
pengzhanbo
32fb93bf35 perf: update deps to latest 2026-04-26 14:22:47 +08:00
pengzhanbo
4614041bbf
feat(plugin-md-power): add logo support for qrcode (#898) 2026-04-26 14:06:21 +08:00
pengzhanbo
a9ddb04acd
feat(plugin-md-power): add support for obsidian callout syntax (#897) 2026-04-26 14:06:06 +08:00
pengzhanbo
3265be84a9
fix(theme): fix collapse interaction failed when sidebar group set link (#896) 2026-04-26 14:05:48 +08:00
pengzhanbo
2bfdec82d7
fix(theme): fix table horizontal overflow on narrow screens (#895) 2026-04-26 14:05:30 +08:00
pengzhanbo
ac63654151 docs: fix skills repo typo 2026-04-25 21:50:49 +08:00
pengzhanbo
6ed5a5c552 docs: update sponsor 2026-04-25 12:17:42 +08:00
pengzhanbo
d69e0b9765 ci: update workflow permissions 2026-04-22 17:07:34 +08:00
pengzhanbo
02038f2df0 build: publish v1.0.0-rc.196 2026-04-19 14:37:52 +08:00
pengzhanbo
e5126663ef fix: fix security 2026-04-19 14:34:47 +08:00
pengzhanbo
402f259086
refactor(plugin-md-power): refactor obsidian plugins (#893) 2026-04-19 14:10:54 +08:00
pengzhanbo
58ea2fc8cb
fix(theme): remove cwd options from picomatch (#892) 2026-04-19 14:10:40 +08:00
pengzhanbo
6ebb1bda6e
fix(plugin-md-power): fix cell display issue caused by colspan in table (#891) 2026-04-19 14:10:22 +08:00
pengzhanbo
68f39695c4 chore: update tsconfig 2026-04-19 14:09:52 +08:00
pengzhanbo
76787f6530 build: publish v1.0.0-rc.195 2026-04-18 17:13:48 +08:00
pengzhanbo
e2b47da532 chore: tweak 2026-04-18 17:09:26 +08:00
pengzhanbo
035d521e96 chore: update deps to latest 2026-04-18 17:07:12 +08:00
pengzhanbo
bfd0c8409c
feat(plugin-md-power): compat obsidian official markdown syntax (#890)
* feat(plugin-md-power): compat obsidian official markdown syntax

* chore: tweak

* chore: tweak

* chore: tweak

* chore: tweak
2026-04-18 17:01:41 +08:00
pengzhanbo
e11c7a8fcd build: publish v1.0.0-rc.194 2026-04-14 15:37:37 +08:00
pengzhanbo
1329051536 chore: tweak 2026-04-14 15:36:15 +08:00
pengzhanbo
0677f6749e chore: update deps to latest 2026-04-14 15:31:38 +08:00
pengzhanbo
28963eb419
fix(plugin-search): fix search index race condition on pageUpdated, close #888 (#889) 2026-04-14 15:29:58 +08:00
pengzhanbo
cfc89adab8 chore: update security deps 2026-04-04 16:35:48 +08:00
pengzhanbo
e0ba59a6f9 build: update changelog 2026-04-03 02:56:28 +08:00
pengzhanbo
352874b29a build: publish v1.0.0-rc.193 2026-04-03 02:25:46 +08:00
pengzhanbo
c824ad85f4 chore: update gitignore 2026-04-03 02:18:23 +08:00
pengzhanbo
db2eda82f3 build: update clean scripts 2026-04-03 02:18:01 +08:00
pengzhanbo
e9fe35bc4f
fix(theme): fix sidebar items prefix not handled correctly, close #876 (#885) 2026-04-03 02:13:18 +08:00
pengzhanbo
709ade741c chore: improve comment 2026-04-03 02:06:32 +08:00
pengzhanbo
d8b79e89e8
refactor(plugin-search): improve search index update (#884) 2026-04-03 01:58:25 +08:00
pengzhanbo
dbc6f0be0f
fix(theme): fix auto-sidebar group icon error inherit, close #873 (#883) 2026-04-02 22:05:54 +08:00
pengzhanbo
9fe294b9dd fix(theme): fix MarkdownOptions types 2026-04-02 21:15:33 +08:00
pengzhanbo
ecf100cfc6 docs: update security.md 2026-04-02 21:15:08 +08:00
pengzhanbo
b7ee45642e docs: update contributing.md 2026-04-02 21:14:51 +08:00
pengzhanbo
54c05c8cea docs: add claude.md 2026-04-02 21:14:34 +08:00
pengzhanbo
86cb872ce6 refactor: migrate onWatched to onPageUpdated 2026-04-02 21:14:16 +08:00
pengzhanbo
a6cb3820b1 refactor: remove deprecated enhancement 2026-04-02 21:12:59 +08:00
pengzhanbo
184d1aee76 build: improve tsdown bundle config 2026-04-02 20:59:23 +08:00
pengzhanbo
cbc5c55891 perf: update deps to latest 2026-04-02 20:57:51 +08:00
mcenahle
4f40f8441d
docs: add "mcenahle Docs" to demo page (#882)
* Update demos.md

* chore: fix URLs for 哦麦 MC logo and preview images

Updated logo and preview image URLs for 哦麦 MC in demos.md.

---------

Co-authored-by: pengzhanbo <volodymyr@foxmail.com>
2026-04-02 20:54:47 +08:00
pengzhanbo
fe0d4bbc92
feat: improve accessibility features (#869) 2026-04-02 20:49:20 +08:00
pengzhanbo
39a76a35d7
feat(plugin-md-power)!: use # as the comment delimiter (#870) 2026-04-02 20:48:55 +08:00
pengzhanbo
a01bc13c66
fix(plugin-md-power): fix tsdown icon (#878) 2026-04-02 20:48:34 +08:00
pengzhanbo
1b213d4c28
fix(theme): add bulletin to outline ignores (#879) 2026-04-02 20:48:10 +08:00
pengzhanbo
aede6f5d87
fix(theme): twoslash comment error (#881)
* fix(theme): fix incorrect auto-sidebar-link parse

* fix(theme): twoslash comment error
2026-04-02 16:38:33 +08:00
pengzhanbo
7febfbf237
fix(theme): fix incorrect auto-sidebar-link parse (#880) 2026-04-02 16:37:58 +08:00
suixinio
7ce4e40521
docs: update ohmymc img (#877)
docs: update ohmymc img
2026-04-02 14:34:49 +08:00
zhenghaoyang24
12c4f5b39e
docs: fix icon documentation links (#874)
- Update relative paths in markdown config documentation to point
  to correct guide location
- Change sidebar icons link from document.md to quick-start/sidebar.md
  for proper navigation structure
2026-04-02 14:34:14 +08:00
pengzhanbo
aa54090b5d docs: update sponsor 2026-03-19 02:09:31 +08:00
逸燧Esyka
192b260d2b
docs: update repository url for Esyka's blog (#872) 2026-03-15 16:41:38 +08:00
逸燧Esyka
75df783295
docs: add Esyka's Blog to demos (#871)
Added Esyka's Blog with relevant details.
2026-03-14 23:39:00 +08:00
pengzhanbo
97a5ba20c3 docs: fix typo 2026-03-08 21:55:27 +08:00
pengzhanbo
896c7e22df chore: improve theme code comments 2026-03-08 16:59:50 +08:00
pengzhanbo
77856e36c5 chore: improve plugin-md-power code comments 2026-03-08 16:35:52 +08:00
pengzhanbo
552f0f5c32 chore: improve plugin-search code comments 2026-03-08 16:16:04 +08:00
pengzhanbo
7751e4c798 chore: improve cli code comments 2026-03-08 16:15:26 +08:00
pengzhanbo
17646708b1 docs: update skills usage doc 2026-03-06 16:40:02 +08:00
pengzhanbo
f14d663bb5 docs: update skills 2026-03-06 15:54:30 +08:00
pengzhanbo
50fa747ec1 docs: update agent skills 2026-03-06 00:44:21 +08:00
221 changed files with 13601 additions and 5665 deletions

View File

@ -13,6 +13,9 @@ on:
workflow_dispatch: workflow_dispatch:
workflow_call: workflow_call:
permissions:
contents: write
jobs: jobs:
deploy-docs: deploy-docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -6,6 +6,9 @@ on:
- v* - v*
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
jobs: jobs:
deploy-docs: deploy-docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -8,6 +8,9 @@ on:
branches: [main] branches: [main]
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -5,6 +5,10 @@ on:
tags: tags:
- v* - v*
permissions:
contents: write
id-token: write
jobs: jobs:
lint: lint:
uses: ./.github/workflows/lint.yaml uses: ./.github/workflows/lint.yaml
@ -16,9 +20,6 @@ jobs:
if: github.repository == 'pengzhanbo/vuepress-theme-plume' if: github.repository == 'pengzhanbo/vuepress-theme-plume'
needs: [test, lint] needs: [test, lint]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:

View File

@ -8,6 +8,9 @@ on:
branches: [main] branches: [main]
workflow_call: workflow_call:
permissions:
contents: read
jobs: jobs:
unit-test: unit-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

3
.gitignore vendored
View File

@ -15,3 +15,6 @@ dist/
coverage/ coverage/
.idea .idea
.claude/
!.claude/skills/

File diff suppressed because it is too large Load Diff

111
CLAUDE.md Normal file
View File

@ -0,0 +1,111 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
vuepress-theme-plume is a VuePress 2 theme monorepo for building blogs, documentation, and knowledge bases.
It includes a main theme, several plugins, a CLI tool, and example implementations.
## Commands
```bash
# Install dependencies
pnpm install
# Build all packages (required after clone, outputs to lib/)
pnpm build
# Development - runs theme + docs dev servers concurrently
pnpm dev
# Lint (eslint + stylelint)
pnpm lint
pnpm lint:fix # auto-fix
# Run tests (vitest)
pnpm test
# Run a single test file
pnpm test src/path/to/file.spec.ts
# Run tests related to changed files (for pre-commit)
cross-env TZ=Etc/UTC vitest related --run
# Build docs only
pnpm docs:build
# Serve docs locally
pnpm docs:serve
# Release workflow
pnpm release # runs lint + build + version bump + changelog + git commit
```
## Monorepo Structure
```txt
├── theme/ # Main VuePress theme (vuepress-theme-plume)
├── plugins/ # VuePress plugins
│ ├── plugin-search/ # Full-text fuzzy search
│ ├── plugin-md-power/ # Markdown enhancements
│ └── plugin-fonts/ # Special character font support
├── cli/ # CLI tool (create project scaffolding)
├── docs/ # Documentation site
└── examples/ # Example implementations
├── pure-blog/
└── layout-slots/
```
## Theme Architecture
The theme is organized into three layers:
- **`src/node/`** - Build-time code (runs during `vuepress build/dev`)
- `prepare/` - Content preparation (frontmatter parsing, collection resolution)
- `plugins/` - VuePress plugin registration
- `config/` - Theme configuration handling
- `autoFrontmatter/` - Automatic frontmatter generation
- **`src/client/`** - Client-side code (runs in browser)
- `components/` - Vue components
- `composables/` - Vue composables (outline, search, etc.)
- `styles/` - CSS/SCSS styles
- `features/` - Feature-specific components and logic
- **`src/shared/`** - Shared code (used by both node and client)
- `frontmatter/` - Frontmatter schemas and utilities
- `locale/` - i18n translations
- `options.ts` - Theme options types
- `features/` - Feature flags and shared feature logic
## Build Output
Each package uses [tsdown](https://tsdown.dev/) to compile TypeScript. Build output goes to `lib/`:
- `lib/node/` - Node-side exports
- `lib/client/` - Client-side exports
- `lib/shared/` - Shared exports
The `lib/` directory is gitignored and must be built with `pnpm build`.
## Testing
Tests use Vitest with coverage enabled. Test files are located at `**/__test__/**/*.spec.ts` and are excluded from coverage reports. Run tests with timezone fixed to UTC to ensure consistent results.
## Key Dependencies
- **VuePress**: v2.0.0-rc.28 with @vuepress/bundler-vite
- **Vue**: ^3.5.30
- **Shiki**: ^4.x for syntax highlighting
- **VueUse**: ^14.x for composables
- **markdown-it**: ^14.x for Markdown processing
## Development Notes
- Node.js 20.19.0+ required
- pnpm catalogs are used for dependency management (`dev`, `peer`, `prod`, `vuepress`)
- The theme depends on `vuepress-plugin-md-power` and `@vuepress-plume/plugin-search` as workspace dependencies
- Some peer dependencies are optional (e.g., artplayer, dashjs, three.js)
- Plugins (`plugins/*`) do not have dev commands — changes require `pnpm build` to take effect
- The `lib/` directory is gitignored and must be rebuilt after `pnpm install`

View File

@ -19,7 +19,7 @@ In the `plugins` directory:
Development requirements: Development requirements:
- [Node.js](http://nodejs.org/) version 20.6.0+ - [Node.js](http://nodejs.org/) version 20.19.0+
- [pnpm](https://pnpm.io/zh/) version 9+ - [pnpm](https://pnpm.io/zh/) version 9+
Clone the repository and install dependencies: Clone the repository and install dependencies:

View File

@ -19,7 +19,7 @@
开发要求: 开发要求:
- [Node.js](http://nodejs.org/) version 20.6.0+ - [Node.js](http://nodejs.org/) version 20.19.0+
- [pnpm](https://pnpm.io/zh/) version 9+ - [pnpm](https://pnpm.io/zh/) version 9+
克隆代码仓库,并安装依赖: 克隆代码仓库,并安装依赖:

View File

@ -4,8 +4,8 @@
| Version | Supported | | Version | Supported |
| ---------------- | ------------------ | | ---------------- | ------------------ |
| >= 1.0.0-rc.170 | :white_check_mark: | | >= 1.0.0-rc.190 | :white_check_mark: |
| < 1.0.0-rc.170 | :x: | | < 1.0.0-rc.190 | :x: |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -1,7 +1,7 @@
{ {
"name": "create-vuepress-theme-plume", "name": "create-vuepress-theme-plume",
"type": "module", "type": "module",
"version": "1.0.0-rc.192", "version": "1.0.0-rc.198",
"description": "The cli for create vuepress-theme-plume's project", "description": "The cli for create vuepress-theme-plume's project",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)", "author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT", "license": "MIT",
@ -27,7 +27,7 @@
"templates" "templates"
], ],
"scripts": { "scripts": {
"build": "tsdown" "build": "tsdown --config-loader unrun"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "catalog:prod", "@clack/prompts": "catalog:prod",
@ -40,8 +40,8 @@
"sort-package-json": "catalog:prod" "sort-package-json": "catalog:prod"
}, },
"plume-deps": { "plume-deps": {
"vuepress": "2.0.0-rc.26", "vuepress": "2.0.0-rc.28",
"vue": "^3.5.26", "vue": "^3.5.32",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },

View File

@ -1,3 +1,15 @@
/**
* VuePress Theme Plume CLI Entry Point
*
* VuePress Theme Plume CLI
*
* This module provides command-line interface for creating and initializing
* VuePress projects with vuepress-theme-plume.
*
* VuePress
*
* @module cli
*/
import cac from 'cac' import cac from 'cac'
import { version } from '../package.json' import { version } from '../package.json'
import { Mode } from './constants.js' import { Mode } from './constants.js'

View File

@ -1,5 +1,10 @@
import type { Locale } from '../types.js' import type { Locale } from '../types.js'
/**
* English locale configuration for CLI prompts and messages.
*
* CLI
*/
export const en: Locale = { export const en: Locale = {
'question.root': 'Where would you want to initialize VuePress?', 'question.root': 'Where would you want to initialize VuePress?',
'question.site.name': 'Site Name:', 'question.site.name': 'Site Name:',

View File

@ -2,6 +2,15 @@ import type { Langs, Locale } from '../types.js'
import { en } from './en.js' import { en } from './en.js'
import { zh } from './zh.js' import { zh } from './zh.js'
/**
* Locale configurations for different languages.
*
*
*
* Maps language codes to their respective locale strings.
*
*
*/
export const locales: Record<Langs, Locale> = { export const locales: Record<Langs, Locale> = {
'zh-CN': zh, 'zh-CN': zh,
'en-US': en, 'en-US': en,

View File

@ -1,5 +1,10 @@
import type { Locale } from '../types.js' import type { Locale } from '../types.js'
/**
* Chinese (Simplified) locale configuration for CLI prompts and messages.
*
* CLI
*/
export const zh: Locale = { export const zh: Locale = {
'question.root': '您想在哪里初始化 VuePress', 'question.root': '您想在哪里初始化 VuePress',
'question.site.name': '站点名称:', 'question.site.name': '站点名称:',

View File

@ -5,6 +5,14 @@ import _sortPackageJson from 'sort-package-json'
import { Mode } from './constants.js' import { Mode } from './constants.js'
import { readJsonFile, resolve } from './utils/index.js' import { readJsonFile, resolve } from './utils/index.js'
/**
* Sort package.json fields in a consistent order.
*
* package.json
*
* @param json - Package.json object to sort / package.json
* @returns Sorted package.json object / package.json
*/
function sortPackageJson(json: Record<any, any>) { function sortPackageJson(json: Record<any, any>) {
return _sortPackageJson(json, { return _sortPackageJson(json, {
sortOrder: ['name', 'type', 'version', 'private', 'description', 'packageManager', 'author', 'license', 'scripts', 'devDependencies', 'dependencies', 'pnpm'], sortOrder: ['name', 'type', 'version', 'private', 'description', 'packageManager', 'author', 'license', 'scripts', 'devDependencies', 'dependencies', 'pnpm'],
@ -111,12 +119,29 @@ export async function createPackageJson(
} }
} }
/**
* Get user information from git global configuration.
*
* git
*
* @returns User information object with username and email /
* @throws Error if git command fails / git
*/
async function getUserInfo() { async function getUserInfo() {
const { output: username } = await spawn('git', ['config', '--global', 'user.name']) const { output: username } = await spawn('git', ['config', '--global', 'user.name'])
const { output: email } = await spawn('git', ['config', '--global', 'user.email']) const { output: email } = await spawn('git', ['config', '--global', 'user.email'])
return { username, email } return { username, email }
} }
/**
* Get the version of a package manager.
*
*
*
* @param pkg - Package manager name (npm, yarn, pnpm) /
* @returns Version string of the package manager /
* @throws Error if package manager command fails /
*/
async function getPackageManagerVersion(pkg: string) { async function getPackageManagerVersion(pkg: string) {
const { output } = await spawn(pkg, ['--version']) const { output } = await spawn(pkg, ['--version'])
return output return output

View File

@ -78,6 +78,15 @@ export async function run(mode: Mode, root?: string): Promise<void> {
} }
} }
/**
* Resolve prompt result into final configuration data.
*
*
*
* @param result - Prompt result from user input /
* @param mode - Operation mode (init or create) /
* @returns Resolved configuration data /
*/
function resolveData(result: PromptResult, mode: Mode): ResolvedData { function resolveData(result: PromptResult, mode: Mode): ResolvedData {
return { return {
...result, ...result,

View File

@ -1,6 +1,19 @@
import type { PackageManager } from '../types.js' import type { PackageManager } from '../types.js'
import process from 'node:process' import process from 'node:process'
/**
* Detect the current package manager from environment variables.
*
* 使
*
* @returns The detected package manager name /
* @example
* // When using pnpm
* const pm = getPackageManager() // returns 'pnpm'
*
* // When using npm
* const pm = getPackageManager() // returns 'npm'
*/
export function getPackageManager(): PackageManager { export function getPackageManager(): PackageManager {
const name = process.env?.npm_config_user_agent || 'npm' const name = process.env?.npm_config_user_agent || 'npm'
return name.split('/')[0] as PackageManager return name.split('/')[0] as PackageManager

View File

@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
'chat', 'chat',
'include', 'include',
'env', 'env',
'obsidian',
], ],
}, },
{ {

View File

@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
'chat', 'chat',
'include', 'include',
'env', 'env',
'obsidian',
], ],
}, },
{ {

View File

@ -94,7 +94,7 @@ export const enNavbar: ThemeNavItem[] = defineNavbarConfig([
{ {
text: `${version}`, text: `${version}`,
icon: 'codicon:versions', icon: 'codicon:versions',
badge: '', badge: 'New',
items: [ items: [
{ text: 'Changelog', link: '/en/changelog/' }, { text: 'Changelog', link: '/en/changelog/' },
{ text: 'Contributing', link: '/en/contributing/' }, { text: 'Contributing', link: '/en/contributing/' },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -58,6 +58,7 @@ export const theme: Theme = plumeTheme({
jsfiddle: true, jsfiddle: true,
demo: true, demo: true,
encrypt: true, encrypt: true,
obsidian: true,
npmTo: ['pnpm', 'yarn', 'npm'], npmTo: ['pnpm', 'yarn', 'npm'],
repl: { repl: {
go: true, go: true,

View File

@ -82,7 +82,7 @@ export default defineUserConfig({
主题提供了 `plume.config.ts` 配置文件,==对该文件的修改支持热更新,无需重启服务=={.tip} ::twemoji:confetti-ball::。 主题提供了 `plume.config.ts` 配置文件,==对该文件的修改支持热更新,无需重启服务=={.tip} ::twemoji:confetti-ball::。
可以在其中配置支持热更新的字段,如 `navbar``profile` 等。 可以在其中配置支持热更新的字段,如 `navbar``profile` 等。
::: tip ::: tip
这些字段仍可在 VuePress 配置文件的 `theme` 中配置,但主题配置文件的设置最终会合并到主配置中。 这些字段仍可在 VuePress 配置文件的 `theme` 中配置,但主题配置文件的设置最终会合并到主配置中。

View File

@ -6,7 +6,7 @@ permalink: /config/locales/
这些选项用于配置与语言相关的文本。 这些选项用于配置与语言相关的文本。
如果你的站点是以非内置语言支持以外的其他语言提供服务的,你应该为每个语言设置这些选项来提供翻译。 如果您的站点是以非内置语言支持以外的其他语言提供服务的,您应该为每个语言设置这些选项来提供翻译。
## 内置语言支持 ## 内置语言支持

View File

@ -149,7 +149,7 @@ export default defineUserConfig({
- **默认值**: `{ provider: 'iconify' }` - **默认值**: `{ provider: 'iconify' }`
- **详情**: 图标配置 - **详情**: 图标配置
[查看 **icon** 使用说明](../../theme/guide/features/icon.md){.read-more} [查看 **icon** 使用说明](../guide/features/icon.md){.read-more}
### plot ### plot

View File

@ -11,7 +11,7 @@ permalink: /config/navigation/
主题默认会自动生成最简单的导航栏配置,仅包括 **首页****文章列表页** 主题默认会自动生成最简单的导航栏配置,仅包括 **首页****文章列表页**
你也可以自己配置导航栏,覆盖默认的的导航栏配置。 您也可以自己配置导航栏,覆盖默认的导航栏配置。
默认配置如下: 默认配置如下:

View File

@ -82,6 +82,7 @@ interface SearchBoxLocale {
### 启用 ### 启用
```ts title=".vuepress/config.ts" twoslash ```ts title=".vuepress/config.ts" twoslash
// @errors: 2353
import { defineUserConfig } from 'vuepress' import { defineUserConfig } from 'vuepress'
import { plumeTheme } from 'vuepress-theme-plume' import { plumeTheme } from 'vuepress-theme-plume'

View File

@ -13,6 +13,7 @@ permalink: /config/watermark/
## 使用 ## 使用
```ts title=".vuepress/config.ts" twoslash ```ts title=".vuepress/config.ts" twoslash
// @errors: 7006
import { defineUserConfig } from 'vuepress' import { defineUserConfig } from 'vuepress'
import { plumeTheme } from 'vuepress-theme-plume' import { plumeTheme } from 'vuepress-theme-plume'

View File

@ -14,8 +14,7 @@ permalink: /config/theme/
::: warning 该字段不支持在 [主题配置文件 `plume.config.js`](./intro.md#主题配置文件) 中进行配置。 ::: warning 该字段不支持在 [主题配置文件 `plume.config.js`](./intro.md#主题配置文件) 中进行配置。
::: :::
无以上声明的字段,你可以在 `.vuepress/config.ts` 或者 `.vuepress/plume.config.ts` 的任意一个文件中 无以上声明的字段,您可以在 `.vuepress/config.ts` 或者 `.vuepress/plume.config.ts` 的任意一个文件中进行配置,一般情况下建议在 `.vuepress/plume.config.ts` 中进行配置。
进行配置,一般情况下建议在 `.vuepress/plume.config.ts` 中进行配置。
::: warning 已经在一个配置文件中进行配置的字段,尽量不要在另一个配置文件中重复配置 ::: warning 已经在一个配置文件中进行配置的字段,尽量不要在另一个配置文件中重复配置
::: :::
@ -441,9 +440,9 @@ export default defineUserConfig({
::: :::
[可以在这里查看 **simple-icons** 所有可用图标](https://icon-sets.iconify.design/simple-icons/){.readmore} [可以在这里查看 **simple-icons** 所有可用图标](https://icon-sets.iconify.design/simple-icons/){.readmore}
如果 **Iconify** 无法满足你的需求,可以传入 `{ svg: string, name?: string }`的格式,使用自定义图标,传入 svg 源码字符串,可选 `name` 字段,用于配置 [`navbarSocialInclude`](#navbarsocialinclude) 如果 **Iconify** 无法满足您的需求,可以传入 `{ svg: string, name?: string }` 格式使用自定义图标,传入 SVG 源码字符串,可选 `name` 字段用于配置 [`navbarSocialInclude`](#navbarsocialinclude)
示例: 示例:

View File

@ -17,7 +17,7 @@ docs:
logo: /plume.png logo: /plume.png
url: https://theme-plume.vuejs.press url: https://theme-plume.vuejs.press
repo: https://github.com/pengzhanbo/vuepress-theme-plume repo: https://github.com/pengzhanbo/vuepress-theme-plume
preview: /images/demos/plume.jpg preview: /images/demos/plume.webp
- -
name: city walk 城市漫步 name: city walk 城市漫步
desc: 致力于汇聚全国350多个城市的户外活动地点与文化场馆的开放数据平台。 desc: 致力于汇聚全国350多个城市的户外活动地点与文化场馆的开放数据平台。
@ -25,12 +25,6 @@ docs:
url: https://shenzhen.citywalk.group/ url: https://shenzhen.citywalk.group/
repo: https://github.com/sunshang-hl/CityWalk repo: https://github.com/sunshang-hl/CityWalk
preview: https://pub-187e90a3327b41ccb8869558b6b8bbc0.r2.dev/city-shenzhen/2024/12/ed251c4438f722dffd6cb95db86c0d56.jpg preview: https://pub-187e90a3327b41ccb8869558b6b8bbc0.r2.dev/city-shenzhen/2024/12/ed251c4438f722dffd6cb95db86c0d56.jpg
-
name: 哦麦 MC
desc: 我的世界教学文档。
logo: https://static.ohmymc.com/img/minecraft-154749_1280.png?max_width=1920&max_height=1920
url: https://ohmymc.com/
preview: https://static.ohmymc.com/img/20241228225159139.png?max_width=1920&max_height=1920
- -
name: NcatBotDocs name: NcatBotDocs
desc: NcatBot一个 QQ 机器人框架项目的使用文档。 desc: NcatBot一个 QQ 机器人框架项目的使用文档。
@ -93,6 +87,12 @@ docs:
logo: https://official.skycraft.cn/i/3.jpg logo: https://official.skycraft.cn/i/3.jpg
url: https://docs.skycraft.cn/ url: https://docs.skycraft.cn/
preview: https://bbsimage.skycraft.cn/docs-preview.jpg preview: https://bbsimage.skycraft.cn/docs-preview.jpg
-
name: mcenahle Docs
desc: mcenahle 的文档网站。
logo: https://d.mcenahle.com/images/logo.png
url: https://d.mcenahle.com/
preview: https://mcenahle.cn/resources/docs-site-preview.jpg
blog: blog:
- -
@ -186,13 +186,6 @@ blog:
url: https://ar0m.com url: https://ar0m.com
repo: https://github.com/jindongjie/blog-vuepress-2025 repo: https://github.com/jindongjie/blog-vuepress-2025
preview: /images/demos/jindongjie.jpg preview: /images/demos/jindongjie.jpg
-
name: 艺述论
desc: 一枚喜受艺术的程序员's blog
logo: https://yishulun.com/avatar.png
url: https://yishulun.com
repo: https://github.com/rixingyike/rixingyike.github.io
preview: /images/demos/yishulun.com.jpg
- -
name: 菲兹克斯喵 name: 菲兹克斯喵
desc: 一名物理系学生的笔记和生活 desc: 一名物理系学生的笔记和生活
@ -269,6 +262,13 @@ blog:
logo: https://raw.githubusercontent.com/Konata9/pic-base/main/pics/20260126223726455.png logo: https://raw.githubusercontent.com/Konata9/pic-base/main/pics/20260126223726455.png
url: https://konata9.cc/ url: https://konata9.cc/
preview: https://raw.githubusercontent.com/Konata9/pic-base/main/pics/20260125225910673.webp preview: https://raw.githubusercontent.com/Konata9/pic-base/main/pics/20260125225910673.webp
-
name: Esyka
desc: Esyka's Blog
logo: https://www.esyka.top/images/logo.png
url: https://www.esyka.top/
repo: https://github.com/esyka114514
preview: https://www.esyka.top/images/preview.png
--- ---
:::important :::important
@ -281,6 +281,12 @@ blog:
[前往 **Github Pull Request** 提交站点](https://github.com/pengzhanbo/vuepress-theme-plume/edit/main/docs/demos.md){.read-more} [前往 **Github Pull Request** 提交站点](https://github.com/pengzhanbo/vuepress-theme-plume/edit/main/docs/demos.md){.read-more}
::: info 案例每半年检查一次,以下情况的站点将会被移除
- 站点链接无法访问
- 已不再使用 vuepress-theme-plume 主题
:::
## 文档 ## 文档
<Demos :list="$frontmatter.docs" /> <Demos :list="$frontmatter.docs" />

View File

@ -124,8 +124,8 @@ export default defineUserConfig({
'/': { '/': {
// Chinese collection configuration // [!code focus:4] // Chinese collection configuration // [!code focus:4]
collections: [ collections: [
{ type: 'post', dir: 'blog', title: '博客' }, { type: 'post', dir: 'blog', title: 'Blog' },
{ type: 'doc', dir: 'typescript', title: 'TypeScript笔记', sidebar: 'auto' } { type: 'doc', dir: 'typescript', title: 'TypeScript Notes', sidebar: 'auto' }
], ],
}, },
'/en/': { '/en/': {
@ -150,8 +150,8 @@ export default defineThemeConfig({
'/': { '/': {
// Chinese collection configuration // [!code focus:4] // Chinese collection configuration // [!code focus:4]
collections: [ collections: [
{ type: 'post', dir: 'blog', title: '博客' }, { type: 'post', dir: 'blog', title: 'Blog' },
{ type: 'doc', dir: 'typescript', title: 'TypeScript笔记', sidebar: 'auto' } { type: 'doc', dir: 'typescript', title: 'TypeScript Notes', sidebar: 'auto' }
], ],
}, },
'/en/': { '/en/': {

View File

@ -159,7 +159,7 @@ The `include` configuration is implemented by the
- **Default:** `{ provider: 'iconify' }` - **Default:** `{ provider: 'iconify' }`
- **Details:** Icon configuration. - **Details:** Icon configuration.
[View **icon** usage instructions](../../theme/guide/features/icon.md){.read-more} [View **icon** usage instructions](../guide/features/icon.md){.read-more}
### plot ### plot

View File

@ -82,6 +82,7 @@ Refer to [Algolia DocSearch Reference](/guide/features/content-search/#algolia-d
### Enable ### Enable
```ts title=".vuepress/config.ts" twoslash ```ts title=".vuepress/config.ts" twoslash
// @errors: 2353
import { defineUserConfig } from 'vuepress' import { defineUserConfig } from 'vuepress'
import { plumeTheme } from 'vuepress-theme-plume' import { plumeTheme } from 'vuepress-theme-plume'

View File

@ -13,6 +13,7 @@ Related plugin: [@vuepress/plugin-watermark](https://ecosystem.vuejs.press/zh/pl
## Usage ## Usage
```ts title=".vuepress/config.ts" twoslash ```ts title=".vuepress/config.ts" twoslash
// @errors: 7006
import { defineUserConfig } from 'vuepress' import { defineUserConfig } from 'vuepress'
import { plumeTheme } from 'vuepress-theme-plume' import { plumeTheme } from 'vuepress-theme-plume'

View File

@ -77,12 +77,12 @@ list:
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
- -
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
socials: socials:
- -
icon: github icon: github
@ -96,7 +96,7 @@ list:
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
location: GuangZhou location: GuangZhou
organization: PengZhanBo organization: PengZhanBo
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
socials: socials:
- -
icon: github icon: github
@ -110,17 +110,17 @@ list:
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
location: GuangZhou location: GuangZhou
organization: PengZhanBo organization: PengZhanBo
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
groups: groups:
- -
title: 分组 1 title: Group 1
desc: 自定义颜色 desc: Custom colors
list: list:
- -
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
backgroundColor: rgb(255,153,0) backgroundColor: rgb(255,153,0)
color: rgb(255,255,153) color: rgb(255,255,153)
nameColor: rgb(255,255,170) nameColor: rgb(255,255,170)
@ -135,7 +135,7 @@ groups:
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
backgroundColor: rgb(255,102,102) backgroundColor: rgb(255,102,102)
color: rgb(255,204,204) color: rgb(255,204,204)
nameColor: rgb(255,238,238) nameColor: rgb(255,238,238)
@ -143,22 +143,22 @@ groups:
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
backgroundColor: rgb(0,153,204) backgroundColor: rgb(0,153,204)
color: rgb(153,238,255) color: rgb(153,238,255)
nameColor: rgb(153,255,255) nameColor: rgb(153,255,255)
- -
title: 分组 2 title: Group 2
desc: 这里是分组 2 的描述文字 desc: Description for Group 2
list: list:
- -
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
- -
name: pengzhanbo name: pengzhanbo
link: https://github.com/pengzhanbo link: https://github.com/pengzhanbo
avatar: https://github.com/pengzhanbo.png avatar: https://github.com/pengzhanbo.png
desc: 即使慢,驰而不息,纵会落后,纵会失败,但必须能够到达他所向的目标。 desc: Even if slow, persist without stop; even if falling behind, even if failing, one must be able to reach the goal they are heading towards.
--- ---

View File

@ -86,7 +86,7 @@ Refer to the [ECharts documentation](https://echarts.apache.org/handbook/zh/get-
## Advanced ## Advanced
You can import and use `defineEchartsConfig` in the You can import and use `defineEchartsConfig` in the
[client configuration file](https://vuejs.press/zh/guide/configuration.html##使用脚本) to customize ECharts: [client configuration file](https://vuejs.press/guide/configuration.html#client-config-file) to customize ECharts:
```ts ```ts
import { defineEchartsConfig } from '@vuepress/plugin-markdown-chart/client' import { defineEchartsConfig } from '@vuepress/plugin-markdown-chart/client'

View File

@ -132,4 +132,4 @@ It can also be placed within a `<CardGrid>` component.
/> />
</CardGrid> </CardGrid>
[View Photography Works Example](../../../../../blog/1.示例/照片类作品示例.md) [View Photography Works Example](/en/demos/)

View File

@ -64,7 +64,7 @@ export default defineUserConfig({
encrypt: { encrypt: {
rules: { rules: {
// Can be the relative path of an MD file to encrypt that file // Can be the relative path of an MD file to encrypt that file
'前端/基础.md': '123456', 'frontend/basics.md': '123456',
// Can be a directory path to encrypt all articles under that directory // Can be a directory path to encrypt all articles under that directory
'/notes/vuepress-theme-plume/': '123456', '/notes/vuepress-theme-plume/': '123456',
// Can be a request path to encrypt all articles under that path // Can be a request path to encrypt all articles under that path

View File

@ -16,7 +16,7 @@ The theme supports icons from the following sources:
Icons are used in the same way across the following theme features: Icons are used in the same way across the following theme features:
- [Navbar Icons](../../config/navbar.md#configuration) - [Navbar Icons](../../config/navbar.md#configuration)
- [Sidebar Icons](../../guide/document.md#sidebar-icons) - [Sidebar Icons](../../guide/quick-start/sidebar.md#visual-enhancement-features)
- [File Tree Icons](../../guide/markdown/file-tree.md) - [File Tree Icons](../../guide/markdown/file-tree.md)
- [Code Group Title Icons](../code/code-tabs.md#group-title-icons) - [Code Group Title Icons](../code/code-tabs.md#group-title-icons)

View File

@ -159,13 +159,13 @@ Right-aligned content
**Input:** **Input:**
````md ````md
The farthest distance in the world Is not the distance between life and death But you don't know I love you when I stand in front of you.[^footnote1] The farthest distance in the world Is not the distance between life and death But you don't know I love you when I stand in front of you.[^footnote1].
[^footnote1]: From India.Rabindranath Tagore **The Farthest Distance in the World** [^footnote1]: From India.Rabindranath Tagore **The Farthest Distance in the World**
```` ````
**Output:** **Output:**
The farthest distance in the world Is not the distance between life and death But you don't know I love you when I stand in front of you.[^footnote1] The farthest distance in the world Is not the distance between life and death But you don't know I love you when I stand in front of you.[^footnote1].
[^footnote1]: From India.Rabindranath Tagore **The Farthest Distance in the World** [^footnote1]: From India.Rabindranath Tagore **The Farthest Distance in the World**

View File

@ -3,6 +3,9 @@ title: File Tree
createTime: 2025/10/08 14:41:57 createTime: 2025/10/08 14:41:57
icon: mdi:file-tree icon: mdi:file-tree
permalink: /en/guide/markdown/file-tree/ permalink: /en/guide/markdown/file-tree/
badge:
text: Change
type: warning
--- ---
## Overview ## Overview
@ -18,7 +21,7 @@ displayed, simply add a slash `/` at the end of the list item.
The following syntax can be used to customize the appearance of the file tree: The following syntax can be used to customize the appearance of the file tree:
- Emphasize file or directory names by making them bold, e.g., `**README.md**` - Emphasize file or directory names by making them bold, e.g., `**README.md**`
- Add comments to files or directories by adding additional text after the name - Add comments to files or directories by appending a comment starting with `#` after the name, for example, `README.md This is a README file`
- Mark files or directories as **added** or **deleted** by prefixing the name with `++` or `--` - Mark files or directories as **added** or **deleted** by prefixing the name with `++` or `--`
- Use `...` or `…` as the name to add placeholder files and directories. - Use `...` or `…` as the name to add placeholder files and directories.
- Add `icon="simple"` or `icon="colored"` after `:::file-tree` to switch to simple icons or colored icons. The default is colored icons. - Add `icon="simple"` or `icon="colored"` after `:::file-tree` to switch to simple icons or colored icons. The default is colored icons.
@ -34,7 +37,7 @@ The following syntax can be used to customize the appearance of the file tree:
- ++ config.ts - ++ config.ts
- -- page1.md - -- page1.md
- README.md - README.md
- theme A **theme** directory - theme # A **theme** directory
- client - client
- components - components
- **Navbar.vue** - **Navbar.vue**
@ -61,7 +64,7 @@ The following syntax can be used to customize the appearance of the file tree:
- ++ config.ts - ++ config.ts
- -- page1.md - -- page1.md
- README.md - README.md
- theme A **theme** directory - theme # A **theme** directory
- client - client
- components - components
- **Navbar.vue** - **Navbar.vue**

View File

@ -0,0 +1,461 @@
---
title: Obsidian Compatibility
icon: simple-icons:obsidian
createTime: 2026/04/17 21:56:55
permalink: /en/guide/markdown/obsidian/
---
## Overview
The theme provides compatibility support for Obsidian's official Markdown extension syntax through the `vuepress-plugin-md-power` plugin,
enabling Obsidian users to write documentation using familiar syntax.
Currently supported Obsidian extension syntax includes:
- [Wiki Links](#wiki-links) - Syntax for inter-page linking
- [Embeds](#embeds) - Embed content from other files into the current page
- [Callout](#callout) - Highlight important information with styled containers
- [Comments](#comments) - Add comments visible only during editing
::: warning No plans to support extension syntax provided by Obsidian's third-party community plugins
:::
## Configuration
Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration:
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
plugins: {
mdPower: {
obsidian: {
wikiLink: true, // Wiki Links
embedLink: true, // Embeds
callout: true, // Callout
comment: true, // Comments
},
pdf: true, // PDF embed functionality
artPlayer: true, // Video embed functionality
}
}
})
})
```
### Configuration Options
:::: field-group
::: field name="wikiLink" type="boolean" default="true" optional
Enable [Wiki Links](#wiki-links) syntax.
:::
::: field name="embedLink" type="boolean" default="true" optional
Enable [Embeds](#embeds) syntax.
:::
::: field name="callout" type="boolean" default="true" optional
Enable [Callout](#callout) syntax.
:::
::: field name="comment" type="boolean" default="true" optional
Enable [Comments](#comments) syntax.
:::
::::
## Wiki Links
Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links.
### Syntax
```md
[[filename]]
[[filename#heading]]
[[filename#heading#subheading]]
[[filename|alias]]
[[filename#heading|alias]]
[[https://example.com|External Link]]
```
### Filename Search Rules
When using Wiki Links, filenames are matched according to the following rules:
**Match Priority:**
1. **Full Path** - Exact match against file paths
2. **Fuzzy Match** - Match filenames at the end of paths, prioritizing the shortest path
**Path Resolution Rules:**
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, prioritizing the shortest path
- **Directory form** (ending with `/`): Matches `README.md` in that directory
**Example:**
Assuming the following document structure:
```txt
docs/
├── README.md
├── guide/
│ ├── README.md
│ └── markdown/
│ └── obsidian.md
```
In `docs/guide/markdown/obsidian.md`:
| Syntax | Match Result |
| ------------------ | ----------------------------------------------------------------------------------------- |
| `[[obsidian]]` | Matches `docs/guide/markdown/obsidian.md` (matched via filename) |
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
### Examples
**External Links:**
**Input:**
```md
[[https://example.com|External Link]]
```
**Output:**
[[https://example.com|External Link]]
**Internal Anchor Links:**
**Input:**
```md
[[npm-to]] <!-- Search by filename -->
[[guide/markdown/math]] <!-- Search by file path -->
[[#Wiki Links]] <!-- Heading on current page -->
[[file-tree#Configuration]] <!-- Search by filename, link to heading -->
```
**Output:**
[[npm-to]]
[[guide/markdown/math]]
[[#Wiki Links]]
[[file-tree#Configuration]]
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
## Embeds
The embed syntax allows you to insert other file resources into the current page.
### Syntax
```md
![[filename]]
![[filename#heading]]
![[filename#heading#subheading]]
```
Filename search rules are the same as [Wiki Links](#filename-search-rules).
::: info Resources starting with `/` or having no path prefix like `./` are loaded from the `public` directory
:::
### Image Embeds
**Syntax:**
```md
![[image]]
![[image|300]]
![[image|300x200]]
```
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
**Example:**
::: demo markdown title="Basic Image" expanded
```md
![[images/custom-hero.jpg]]
```
:::
::: demo markdown title="Set Width" expanded
```md
![[images/custom-hero.jpg|300]]
```
:::
::: demo markdown title="Set Width and Height" expanded
```md
![[images/custom-hero.jpg|300x200]]
```
:::
### PDF Embeds
> [!NOTE]
> PDF embeds require the `markdown.pdf` plugin to be enabled for proper functionality.
**Syntax:**
```md
![[document.pdf]]
![[document.pdf#page=1]] <!-- #page=1 means first page -->
![[document.pdf#page=1#height=300]] <!-- #page=page number #height=height -->
```
Supported formats: `pdf`
---
### Audio Embeds
**Syntax:**
```md
![[audio file]]
```
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
---
### Video Embeds
> [!NOTE]
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
**Syntax:**
```md
![[video file]]
![[video file#height=400]] <!-- Set video height -->
```
Supported formats: `mp4`, `webm`, `mov`, etc.
---
### Content Fragment Embeds
Content fragments under a specified heading can be embedded using `#heading`:
**Input:**
```md
![[my-note]]
![[my-note#Heading One]]
![[my-note#Heading One#Subheading]]
```
[Obsidian Official - **Insert Files**](https://obsidian.md/en/help/embeds){.readmore}
[Obsidian Official - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore}
## Callout
Callout is a syntax for highlighting important information, similar to VuePress's `::: hint` container syntax.
### Syntax
```md
> [!note]
> Content
```
**Optional Title:**
```md
> [!tip] Custom Title
> Content
```
### Types
Callout supports the following types, with aliases automatically mapped to their corresponding primary types:
| Type | Aliases | Description |
| ---- | ------- | ----------- |
| `note` | `quote`, `cite` | Notes, quotes |
| `tip` | `hint` | Tips, hints |
| `info` | `todo` | Information, todos |
| `success` | `check`, `done` | Success, done |
| `warning` | `question`, `help`, `faq` | Warnings, questions, help |
| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | Caution, failure, danger |
| `important` | `example` | Important, examples |
| `details` | `abstract`, `summary`, `tldr` | Details, summary |
### Examples
**Basic Usage:**
**Input:**
```md
> [!NOTE]
> This is a note callout.
```
**Output:**
> [!NOTE]
> This is a note callout.
---
**With Title:**
**Input:**
```md
> [!TIP] Useful Tip
> Using `pnpm` can significantly speed up dependency installation.
```
**Output:**
> [!TIP] Useful Tip
> Using `pnpm` can significantly speed up dependency installation.
---
**Multiple Types:**
**Input:**
```md
> [!success]
> Operation completed successfully!
>
> [!warning]
> This is a warning message.
>
> [!caution]
> Please proceed with caution, this action cannot be undone.
```
**Output:**
> [!success]
> Operation completed successfully!
> [!warning]
> This is a warning message.
> [!caution]
> Please proceed with caution, this action cannot be undone.
---
**Details Type:**
The `details` type renders as an HTML `<details>` element, supporting collapse/expand:
**Input:**
```md
> [!details]
> Click to expand more content
>
> This is hidden content.
```
**Output:**
> [!details]
> Click to expand more content
>
> This is hidden content.
[Obsidian Official - **Callout**](https://obsidian.md/en/help/callouts){.readmore}
## Comments
Content wrapped in `%%` is treated as a comment and will not be rendered on the page.
### Syntax
**Inline Comments:**
```md
This is an %%inline comment%% example.
```
**Block Comments:**
```md
%%
This is a block comment.
It can span multiple lines.
%%
```
### Examples
**Inline Comments:**
**Input:**
```md
This is an %%inline comment%% example.
```
**Output:**
This is an %%inline comment%% example.
---
**Block Comments:**
**Input:**
```md
Content before the comment
%%
This is a block comment.
It can span multiple lines.
%%
Content after the comment
```
**Output:**
Content before the comment
%%
This is a block comment.
%%
It can span multiple lines.
[Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore}
## Notes
- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality
- Some Obsidian-specific features (such as internal link graph views, bidirectional links, etc.) are outside the scope of this support
- When embedding content, the embedded page also participates in the theme's build process
- PDF embeds require the `markdown.pdf` plugin to be enabled simultaneously
- Video embeds require the `markdown.artPlayer` plugin to be enabled simultaneously
- Embed resources starting with `/` or using `./` form are loaded from the `public` directory

View File

@ -6,7 +6,7 @@ permalink: /en/guide/markdown/qrcode/
badge: New badge: New
--- ---
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume) @[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
## Overview ## Overview
@ -42,7 +42,7 @@ Inline syntax is suitable for shorter `text`, such as links.
<!-- Basic Syntax --> <!-- Basic Syntax -->
@[qrcode](text) @[qrcode](text)
<!-- With Attributes --> <!-- With Attributes -->
@[qrcode card svg title="xxx" align="center"](text) @[qrcode card title="xxx" align="center" logo="/plume.png"](text)
``` ```
### Container Syntax ### Container Syntax
@ -50,7 +50,7 @@ Inline syntax is suitable for shorter `text`, such as links.
Container syntax is suitable for longer `text`, such as paragraphs or multi-line text. Container syntax is suitable for longer `text`, such as paragraphs or multi-line text.
```md ```md
::: qrcode card svg title="xxx" align="center" ::: qrcode card title="xxx" align="center"
text text
::: :::
``` ```
@ -64,8 +64,13 @@ text
::: field name="card" type="boolean" optional default="false" ::: field name="card" type="boolean" optional default="false"
Whether to enable the card style. Whether to enable the card style.
::: :::
::: field name="svg" type="boolean" optional default="false" ::: field name="logo" type="string" optional
Whether to render the QR code in SVG format. The default format is PNG. The path to the logo image displayed at the center of the QR code.
Only absolute paths are supported.
:::
::: field name="logoSize" type="number" optional default="0.2"
The size ratio of the logo relative to the QR code.
::: :::
::: field name="title" type="string" optional ::: field name="title" type="string" optional
The title of the QR code. The title of the QR code.
@ -100,6 +105,8 @@ Four levels are available depending on the operating environment.
Higher levels provide better error resistance but reduce the data capacity of the symbol. Higher levels provide better error resistance but reduce the data capacity of the symbol.
If the QR code symbol is unlikely to be damaged, lower error correction levels like Low or Medium can be safely used. If the QR code symbol is unlikely to be damaged, lower error correction levels like Low or Medium can be safely used.
When the QR code contains a logo, the default value is `H`.
::: :::
::: field name="version" type="number" optional ::: field name="version" type="number" optional
**QR Code Version** **QR Code Version**
@ -129,8 +136,23 @@ If not specified, a more suitable value will be automatically calculated.
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume) @[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
### QR Code with Logo
**Input:**
```md
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
```
**Output:**
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
### Internal Page Path ### Internal Page Path
::: tip Internal page links will automatically have a logo added
:::
**Input:** **Input:**
```md ```md

View File

@ -94,7 +94,7 @@ export default defineUserConfig({
{ {
type: 'doc', type: 'doc',
dir: 'guide', dir: 'guide',
title: '指南', title: 'Guide',
// autoFrontmatter: true, // Theme built-in configuration // autoFrontmatter: true, // Theme built-in configuration
autoFrontmatter: { autoFrontmatter: {
title: true, // Auto-generate title title: true, // Auto-generate title
@ -117,7 +117,7 @@ export default defineThemeConfig({
{ {
type: 'doc', type: 'doc',
dir: 'guide', dir: 'guide',
title: '指南', title: 'Guide',
// autoFrontmatter: true, // Theme built-in configuration // autoFrontmatter: true, // Theme built-in configuration
autoFrontmatter: { autoFrontmatter: {
title: true, // Auto-generate title title: true, // Auto-generate title
@ -297,23 +297,23 @@ Example:
::: code-tree ::: code-tree
```md title="docs/blog/服务.md" ```md title="docs/blog/service.md"
--- ---
title: 服务 title: Service
permalink: /blog/wu-fu/ permalink: /blog/service/
--- ---
``` ```
```md title="docs/blog/都城.md" ```md title="docs/blog/capital.md"
--- ---
title: 都城 title: Capital
permalink: /blog/dou-cheng/ permalink: /blog/capital/
--- ---
``` ```
::: :::
You probably noticed that in the example, the permalink for the `都城.md` file is `/blog/dou-cheng/`, You probably noticed that in the example, the permalink for the `capital.md` file is `/blog/capital/`,
which is incorrect. This is because `pinyin-pro`'s default dictionary cannot accurately identify polyphonic which is incorrect. This is because `pinyin-pro`'s default dictionary cannot accurately identify polyphonic
characters. If you need a more precise conversion result,you can manually install `@pinyin-pro/data`, characters. If you need a more precise conversion result,you can manually install `@pinyin-pro/data`,
and the theme will automatically load this dictionary to improve accuracy. and the theme will automatically load this dictionary to improve accuracy.
@ -326,9 +326,9 @@ npm i @pinyin-pro/data
::: :::
```md title="docs/blog/都城.md" ```md title="docs/blog/capital.md"
--- ---
title: 都城 title: Capital
permalink: /blog/du-cheng/ permalink: /blog/capital/
--- ---
``` ```

View File

@ -760,8 +760,6 @@ Automatically switches to `top` layout on narrow-screen devices to ensure displa
## Article Metadata ## Article Metadata
## 文章元数据
In the collection, the `meta` option allows you to set the display method of article metadata, In the collection, the `meta` option allows you to set the display method of article metadata,
This setting will directly affect the display of metadata on both the **article list page** and the **article content page**: This setting will directly affect the display of metadata on both the **article list page** and the **article content page**:
@ -779,7 +777,7 @@ export default defineUserConfig({
{ {
type: 'post', type: 'post',
dir: 'blog', dir: 'blog',
title: '博客', title: 'Blog',
// [!code hl:11] // [!code hl:11]
meta: { meta: {
tags: true, // Whether to display labels tags: true, // Whether to display labels
@ -808,7 +806,7 @@ export default defineThemeConfig({
{ {
type: 'post', type: 'post',
dir: 'blog', dir: 'blog',
title: '博客', title: 'Blog',
// [!code hl:11] // [!code hl:11]
meta: { meta: {
tags: true, // Whether to display labels tags: true, // Whether to display labels

View File

@ -28,10 +28,10 @@ A typical VuePress static site has the following file structure:
:::file-tree :::file-tree
- my-site - my-site
- docs \# Source directory - docs # Source directory
- .vuepress/ - .vuepress/
- … - …
- README.md \# Homepage - README.md # Homepage
- package.json - package.json
::: :::

View File

@ -13,7 +13,7 @@ whether you are creating a **technical blog**, **personal journal**, **product d
**knowledge base**, or **tutorial series**. **knowledge base**, or **tutorial series**.
Deep optimizations have been applied to typography and image display, Deep optimizations have been applied to typography and image display,
with extensive enhancements specifically for Markdown syntax.This allows you to effortlessly with extensive enhancements specifically for Markdown syntax. This allows you to effortlessly
create professional content that is **aesthetically pleasing, highly readable, and expressive**. create professional content that is **aesthetically pleasing, highly readable, and expressive**.
::: details New to VuePress? ::: details New to VuePress?
@ -42,7 +42,7 @@ a more beautiful, clean, and user-friendly reading experience.
==content encryption==, and ==article watermarking==. ==content encryption==, and ==article watermarking==.
- **Code Presentation**: Support for code block ==grouping==, ==collapsing==, ==focusing==, - **Code Presentation**: Support for code block ==grouping==, ==collapsing==, ==focusing==,
==line highlighting==, ==diff comparison==, and embedding ==code demos== from platforms like CodePen, JSFiddle, and CodeSandbox. ==line highlighting==, ==diff comparison==, and embedding ==code demos== from platforms like CodePen, JSFiddle, and CodeSandbox.
- **Icon System**: Integration with [iconify](https://icon-sets.iconify.d/) providing access to - **Icon System**: Integration with [iconify](https://icon-sets.iconify.design/) providing access to
**200,000+** ==icons==, with optional support for `iconfont` / `fontawesome` icon libraries. **200,000+** ==icons==, with optional support for `iconfont` / `fontawesome` icon libraries.
- **Media Embedding**: Support for ==PDF embedding==, and ==Bilibili/Youtube/Local Video== embedding. - **Media Embedding**: Support for ==PDF embedding==, and ==Bilibili/Youtube/Local Video== embedding.
- **Chart Rendering**: Integration with multiple ==chart engines== including chart.js, Echarts, Mermaid, Flowchart, Markmap, and PlantUML. - **Chart Rendering**: Integration with multiple ==chart engines== including chart.js, Echarts, Mermaid, Flowchart, Markmap, and PlantUML.

View File

@ -13,22 +13,22 @@ For projects created via the [command-line tool](./usage.md#command-line-install
::: file-tree ::: file-tree
- .git/ - .git/
- **docs** \# Documentation source directory - **docs** # Documentation source directory
- .vuepress \# VuePress configuration directory - .vuepress/ # VuePress configuration directory
- public/ \# Static assets - public/ # Static assets
- client.ts \# Client configuration (optional) - client.ts # Client configuration (optional)
- collections.ts \# Collections configuration (optional) - collections.ts # Collections configuration (optional)
- config.ts \# VuePress main configuration - config.ts # VuePress main configuration
- navbar.ts \# Navbar configuration (optional) - navbar.ts # Navbar configuration (optional)
- plume.config.ts \# Theme configuration file (optional) - plume.config.ts # Theme configuration file (optional)
- demo \# `doc` type collection - demo # `doc` type collection
- foo.md - foo.md
- bar.md - bar.md
- blog \# `post` type collection - blog # `post` type collection
- preview \# Blog category - preview # Blog category
- markdown.md \# Category article - markdown.md # Category article
- article.md \# Blog article - article.md # Blog article
- README.md \# Site homepage - README.md # Site homepage
- … - …
- package.json - package.json
- pnpm-lock.yaml - pnpm-lock.yaml

View File

@ -35,7 +35,7 @@ Page content starts after the second `---`.
Frontmatter is a configuration block using Frontmatter is a configuration block using
[YAML](https://dev.to/paulasantamaria/introduction-to-yaml-125f) format, located at the top of a Markdown file and delimited by `---`. [YAML](https://dev.to/paulasantamaria/introduction-to-yaml-125f) format, located at the top of a Markdown file and delimited by `---`.
It is recommended to read the [Frontmatter Detailed Guide](../../../../4.教程/frontmatter.md) for the complete syntax specification. It is recommended to read the [Frontmatter Detailed Guide](../auto-frontmatter.md) for the complete syntax specification.
::: :::
## Automatic Frontmatter Generation ## Automatic Frontmatter Generation
@ -139,14 +139,14 @@ The numeric part serves as the **sorting basis**. Directories without numbers ar
::: file-tree ::: file-tree
- docs - docs
- blog \# post type collection - blog # post type collection
- 1.Frontend - 1.Frontend
- 1.html/ - 1.html/
- 2.css/ - 2.css/
- 3.javascript/ - 3.javascript/
- 2.Backend/ - 2.Backend/
- DevOps/ - DevOps/
- typescript \# doc type collection - typescript # doc type collection
- 1.Basics - 1.Basics
- 1.Variables.md - 1.Variables.md
- 2.Types.md - 2.Types.md

View File

@ -5,19 +5,19 @@ createTime: 2024/04/22 09:44:37
permalink: /en/guide/repl/kotlin/ permalink: /en/guide/repl/kotlin/
--- ---
## 概述 ## Overview
主题提供了 Kotlin 代码演示,支持 在线运行 Kotlin 代码。 The theme provides Kotlin code demonstrations, supporting online execution of Kotlin code.
::: important ::: important
该功能通过将 代码提交到 服务器 进行 编译并执行,且一次只能提交单个代码文件。 This feature works by submitting code to a server for compilation and execution, and only a single code file can be submitted at a time.
因此,请不要使用此功能 执行 过于复杂的代码,也不要过于频繁的进行执行请求。 Therefore, please do not use this feature to execute overly complex code, and avoid making execution requests too frequently.
::: :::
## 配置 ## Configuration
该功能默认不启用,你可以通过配置来启用它。 This feature is disabled by default. You can enable it through configuration.
```ts title=".vuepress/config.ts" ```ts title=".vuepress/config.ts"
export default defineUserConfig({ export default defineUserConfig({
@ -31,39 +31,39 @@ export default defineUserConfig({
}) })
``` ```
## 使用 ## Usage
使用 `::: kotlin-repl` 容器语法 将 kotlin 代码块包裹起来。主题会检查代码块并添加执行按钮。 Use the `::: kotlin-repl` container syntax to wrap Kotlin code blocks. The theme will inspect the code block and add an execution button.
### 只读代码演示 ### Read-Only Code Demo
kotlin 代码演示默认是只读的,不可编辑。 Kotlin code demos are read-only by default and cannot be edited.
````md ````md
::: kotlin-repl title="自定义标题" ::: kotlin-repl title="Custom Title"
```kotlin ```kotlin
// your kotlin code // your kotlin code
``` ```
::: :::
```` ````
### 可编辑代码演示 ### Editable Code Demo
如果需要在线编辑并执行,需要将代码块包裹在 `::: kotlin-repl editable` 容器语法中 If you need online editing and execution, wrap the code block in the `::: kotlin-repl editable` container syntax.
````md ````md
::: kotlin-repl editable title="自定义标题" ::: kotlin-repl editable title="Custom Title"
```kotlin ```kotlin
// your kotlin code // your kotlin code
``` ```
::: :::
```` ````
## 示例 ## Examples
### 打印内容 ### Print Content
**输入:** **Input:**
````md ````md
::: kotlin-repl ::: kotlin-repl
@ -78,7 +78,7 @@ fun main(args: Array<String>) {
::: :::
```` ````
**输出:** **Output:**
::: kotlin-repl ::: kotlin-repl
@ -93,7 +93,7 @@ fun main(args: Array<String>) {
::: :::
### 运算 ### Computation
::: kotlin-repl ::: kotlin-repl
@ -109,9 +109,9 @@ fun main(args: Array<String>) {
::: :::
### 可编辑代码演示 ### Editable Code Demo
**输入:** **Input:**
````md ````md
::: kotlin-repl editable ::: kotlin-repl editable
@ -126,7 +126,7 @@ fun main(args: Array<String>) {
::: :::
```` ````
**输出:** **Output:**
::: kotlin-repl editable ::: kotlin-repl editable

View File

@ -51,7 +51,7 @@ export default defineUserConfig({
::: :::
```` ````
标配置请查看 [chart.js] 文档 表配置请查看 [chart.js] 文档
## 示例 ## 示例

View File

@ -7,7 +7,7 @@ permalink: /guide/code/group/
## 概述 ## 概述
代码组Code Tabs 主题 中用于并排展示多个相关代码片段的强大功能。 代码组Code Tabs是主题中用于并排展示多个相关代码片段的强大功能。
通过标签页形式组织代码,您可以清晰对比不同技术栈、配置方案或语言版本的实现差异。 通过标签页形式组织代码,您可以清晰对比不同技术栈、配置方案或语言版本的实现差异。
## 基础语法 ## 基础语法

View File

@ -641,7 +641,7 @@ str = 'Hello'
你应该能够在控制台中查看到相关的错误信息,然后在错误信息的 `description` 中找到对应的错误码。 你应该能够在控制台中查看到相关的错误信息,然后在错误信息的 `description` 中找到对应的错误码。
然后再将错误码添加到 `@errors` 中。 然后再将错误码添加到 `@errors` 中。
不用担心变异失败会终止进程,主题会在编译失败时显示错误信息,同时在代码块中输出未编译的代码。 不用担心编译失败会终止进程,主题会在编译失败时显示错误信息,同时在代码块中输出未编译的代码。
::: :::
### `@noErrors` ### `@noErrors`

View File

@ -7,7 +7,7 @@ permalink: /guide/components/card-masonry/
## 概述 ## 概述
瀑布流容器是一个 通用的容器组件,你可以把任何内容放到 `<CardMasonry>` 里面,容器会自动计算每一个 **项** 的高度, 瀑布流容器是一个通用的容器组件,你可以把任何内容放到 `<CardMasonry>` 里面,容器会自动计算每一个 **项** 的高度,
然后将它们按照瀑布流的方式进行排列。 然后将它们按照瀑布流的方式进行排列。
::: details 什么是项 ::: details 什么是项

View File

@ -7,7 +7,7 @@ permalink: /guide/components/home-box/
## 首页布局容器 ## 首页布局容器
自定义首页时,使用 `<HomeBox>` 提供给 区域 包装容器。 自定义首页时,使用 `<HomeBox>` 提供给区域的包装容器。
## Props ## Props

View File

@ -9,7 +9,7 @@ permalink: /guide/components/image-card/
使用 `<ImageCard>` 组件在页面中显示图片卡片。 使用 `<ImageCard>` 组件在页面中显示图片卡片。
图片卡片 有别于 markdown 的 普通插入图片方式,它展示与图片相关的更多信息,包括标题、描述、作者、链接等。 图片卡片有别于 markdown 的普通插入图片方式,它展示与图片相关的更多信息,包括标题、描述、作者、链接等。
适用于如 摄影作品、设计作品、宣传海报 等场景。 适用于如 摄影作品、设计作品、宣传海报 等场景。
## Props ## Props

View File

@ -12,7 +12,7 @@ import NpmBadgeGroup from 'vuepress-theme-plume/features/NpmBadgeGroup.vue'
## 概述 ## 概述
Npm 徽章组件 用于显示 npm 包信息,并提供相关的链接。 Npm 徽章组件用于显示 npm 包信息,并提供相关的链接。
徽章由 <https://shields.io> 提供支持。 徽章由 <https://shields.io> 提供支持。

View File

@ -1,5 +1,5 @@
--- ---
title: “隐秘”文本 title: “隐秘” 文本
icon: lets-icons:hide-eye icon: lets-icons:hide-eye
createTime: 2024/08/18 23:02:39 createTime: 2024/08/18 23:02:39
permalink: /guide/components/plot/ permalink: /guide/components/plot/
@ -7,7 +7,7 @@ permalink: /guide/components/plot/
## 概述 ## 概述
使用 `<Plot>` 组件显示 [“隐秘”文本](../markdown/plot.md) ,能够更灵活的控制行为。 使用 `<Plot>` 组件显示 ["隐秘"文本](../markdown/plot.md),能够更灵活地控制行为。
该组件默认不启用,你需要在 theme 配置中启用。 该组件默认不启用,你需要在 theme 配置中启用。

View File

@ -11,7 +11,7 @@ import RepoCard from 'vuepress-theme-plume/features/RepoCard.vue'
## 概述 ## 概述
Repo 卡片组件 用于显示 GitHub / Gitee 仓库信息。 Repo 卡片组件用于显示 GitHub / Gitee 仓库信息。
## 使用 ## 使用

View File

@ -8,8 +8,8 @@ badge: 新
## 概述 ## 概述
对于大多数的站点而言,一个 **炫酷好看** 首页首屏,能够更容易的吸引用户停留下来。 对于大多数的站点而言,一个 **炫酷好看** 的首页首屏,能够更容易地吸引用户停留下来。
但实现 **炫酷好看** 往往需要比较复杂的技术成本,以及一些不错的灵感。 但实现 **炫酷好看** 的效果往往需要比较复杂的技术成本,以及一些不错的灵感。
主题对 **首页****Hero** 部分,内置了一系列 **炫酷好看** 的背景效果, 主题对 **首页****Hero** 部分,内置了一系列 **炫酷好看** 的背景效果,
通过简单的配置即可应用到你的站点首页中: 通过简单的配置即可应用到你的站点首页中:

View File

@ -320,7 +320,7 @@ const {
公告板的唯一标识由 `bulletin.id` 配置。 公告板的唯一标识由 `bulletin.id` 配置。
唯一标识是用于区分公告板,并根据 该表示 决定 `bulletin.lifetime` 的有效期。 唯一标识是用于区分公告板,并根据该标识决定 `bulletin.lifetime` 的有效期。
```ts ```ts
export default defineUserConfig({ export default defineUserConfig({

View File

@ -122,7 +122,7 @@ export default defineUserConfig({
贡献者信息列表。 贡献者信息列表。
用户在本地 git 服务中配置的 用户名和邮箱 可能与 git 托管服务(如 github、gitlab、gitee的用户信息不一致。 用户在本地 git 服务中配置的用户名和邮箱可能与 git 托管服务(如 github、gitlab、gitee的用户信息不一致。
可以在此预先配置贡献者信息。 可以在此预先配置贡献者信息。
(对于非 github 的其他 git 托管服务,诸如 gitlab、gitee由于不能通过用户名直接获取头像和用户地址请在此 (对于非 github 的其他 git 托管服务,诸如 gitlab、gitee由于不能通过用户名直接获取头像和用户地址请在此

View File

@ -16,7 +16,7 @@ permalink: /guide/features/icon/
在主题的以下功能中以相同的方式使用图标: 在主题的以下功能中以相同的方式使用图标:
- [导航栏图标](../../config/navbar.md#配置) - [导航栏图标](../../config/navbar.md#配置)
- [侧边栏图标](../../guide/document.md#侧边栏图标) - [侧边栏图标](../quick-start/sidebar.md#视觉增强功能)
- [文件树图标](../../guide/markdown/file-tree.md) - [文件树图标](../../guide/markdown/file-tree.md)
- [代码分组标题图标](../code/code-tabs.md#分组标题图标) - [代码分组标题图标](../code/code-tabs.md#分组标题图标)

View File

@ -26,16 +26,15 @@ tags:
内部和外部链接都会被特殊处理。 内部和外部链接都会被特殊处理。
主题默认对每个 md 文件自动生成一个新的 链接,并保存在对应的 md 文件的 frontmatter 的 `permalink` 中。 主题默认对每个 Markdown 文件自动生成一个新的链接,并保存在对应的 Markdown 文件的 frontmatter 的 `permalink` 中。您可以随时修改它们。您也可以通过 `theme.autoFrontmatter` 选项来禁用这个功能,这时会恢复为 VuePress 的默认行为。
你可以随时修改它们。你也可以通过 `theme.autoFrontmatter` 选项来禁用这个功能,这时会恢复为 VuePress 的默认行为。
### 内部链接 ### 内部链接
有三种方式来使用内部链接: 有三种方式来使用内部链接:
- 使用 生成的 `permalink` 作为内部链接的目标。 - 使用生成的 `permalink` 作为内部链接的目标。
- 使用 md 文件的相对路径作为内部链接的目标。 - 使用 Markdown 文件的相对路径作为内部链接的目标。
- 使用 md 文件的绝对路径作为内部链接的目标, 绝对路径 `/` 表示从 `${sourceDir}` 目录开始。 - 使用 Markdown 文件的绝对路径作为内部链接的目标,绝对路径 `/` 表示从 `${sourceDir}` 目录开始。
```md ```md
[Markdown](/guide/markdown/) [Markdown](/guide/markdown/)

View File

@ -3,6 +3,9 @@ title: 文件树
createTime: 2024/09/30 14:41:57 createTime: 2024/09/30 14:41:57
icon: mdi:file-tree icon: mdi:file-tree
permalink: /guide/markdown/file-tree/ permalink: /guide/markdown/file-tree/
badge:
text: 变更
type: warning
--- ---
## 概述 ## 概述
@ -17,12 +20,19 @@ permalink: /guide/markdown/file-tree/
以下语法可用于自定义文件树的外观: 以下语法可用于自定义文件树的外观:
- 通过加粗文件名或目录名来突出显示,例如 `**README.md**` - 通过加粗文件名或目录名来突出显示,例如 `**README.md**`
- 通过在名称后添加更多文本来为文件或目录添加注释 - 通过在名称后添加`#` 开头的注释来为文件或目录添加注释,例如 `README.md # 这是一个 README 文件`
- 通过在名称前添加 `++``--` 来标记文件或目录为 **新增** 或 **删除** - 通过在名称前添加 `++``--` 来标记文件或目录为 **新增** 或 **删除**
- 使用 `...``…` 作为名称来添加占位符文件和目录。 - 使用 `...``…` 作为名称来添加占位符文件和目录。
- 在 `:::file-tree` 后添加 `icon="simple"` 或 添加 `icon="colored"` 可以切换为简单图标或彩色图标,默认为彩色图标。 - 在 `:::file-tree` 后添加 `icon="simple"` 或 添加 `icon="colored"` 可以切换为简单图标或彩色图标,默认为彩色图标。
- 在 `:::file-tree` 后添加 `title="xxxx"` 可以为文件树添加标题。 - 在 `:::file-tree` 后添加 `title="xxxx"` 可以为文件树添加标题。
::: important `rc.193` 主题更新说明
过去 `file-tree` 使用 **空格** 来区分文件名和注释,这在某些情况下会导致问题,例如文件名中包含空格时。
为了解决这个问题,我们引入了 **# 号注释** 语法,您可以在文件名后添加以 `#` 开头的注释,例如 `README.md # 这是一个 README 文件`
**此修改为 ==破坏性更新=={.danger} 更新。**
:::
**输入:** **输入:**
```md /++/ /--/ ```md /++/ /--/
@ -33,7 +43,7 @@ permalink: /guide/markdown/file-tree/
- ++ config.ts - ++ config.ts
- -- page1.md - -- page1.md
- README.md - README.md
- theme 一个 **主题** 目录 - theme # 一个 **主题** 目录
- client - client
- components - components
- **Navbar.vue** - **Navbar.vue**
@ -60,7 +70,7 @@ permalink: /guide/markdown/file-tree/
- ++ config.ts - ++ config.ts
- -- page1.md - -- page1.md
- README.md - README.md
- theme 一个 **主题** 目录 - theme # 一个 **主题** 目录
- client - client
- components - components
- **Navbar.vue** - **Navbar.vue**

View File

@ -0,0 +1,460 @@
---
title: Obsidian 兼容
icon: simple-icons:obsidian
createTime: 2026/04/17 21:56:55
permalink: /guide/markdown/obsidian/
---
## 概述
主题通过 `vuepress-plugin-md-power` 插件提供对 Obsidian 官方 Markdown 扩展语法的兼容性支持,使 Obsidian 用户能够以熟悉的语法撰写文档。
当前已支持的 Obsidian 扩展语法包括:
- [Wiki 链接](#wiki-链接) - 页面间相互链接的语法
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
- [Callout](#callout) - 使用样式容器突出显示重要信息
- [注释](#注释) - 添加仅在编辑时可见的注释
::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法
:::
## 配置
Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用:
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
plugins: {
mdPower: {
obsidian: {
wikiLink: true, // Wiki 链接
embedLink: true, // 嵌入内容
callout: true, // Callout
comment: true, // 注释
},
pdf: true, // PDF 嵌入功能
artPlayer: true, // 视频嵌入功能
}
}
})
})
```
### 配置项
:::: field-group
::: field name="wikiLink" type="boolean" default="true" optional
启用 [Wiki 链接](#wiki-链接) 语法。
:::
::: field name="embedLink" type="boolean" default="true" optional
启用 [嵌入内容](#嵌入内容) 语法。
:::
::: field name="callout" type="boolean" default="true" optional
启用 [Callout](#callout) 语法。
:::
::: field name="comment" type="boolean" default="true" optional
启用 [注释](#注释) 语法。
:::
::::
## Wiki 链接
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。
### 语法
```md
[[文件名]]
[[文件名#标题]]
[[文件名#标题#子标题]]
[[文件名|别名]]
[[文件名#标题|别名]]
[[https://example.com|外部链接]]
```
### 文件名搜索规则
当使用 Wiki 链接时,文件名会按照以下规则进行搜索匹配:
**匹配优先级:**
1. **完整路径** - 精确匹配文件路径
2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径
**路径解析规则:**
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md`
**示例:**
假设文档结构如下:
```txt
docs/
├── README.md
├── guide/
│ ├── README.md
│ └── markdown/
│ └── obsidian.md
```
`docs/guide/markdown/obsidian.md` 中:
| 语法 | 匹配结果 |
| -------------- | -------------------------------------------------------- |
| `[[obsidian]]` | 匹配 `docs/guide/markdown/obsidian.md`(通过文件名检索) |
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
### 示例
**外部链接:**
**输入:**
```md
[[https://example.com|外部链接]]
```
**输出:**
[[https://example.com|外部链接]]
**内部锚点链接:**
**输入:**
```md
[[npm-to]] <!-- 通过文件名检索 -->
[[guide/markdown/math]] <!-- 通过文件路径检索-->
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
[[file-tree#配置]] <!-- 通过文件名检索,并链接到 heading -->
```
**输出:**
[[npm-to]]
[[guide/markdown/math]]
[[#Wiki 链接]]
[[file-tree#配置]]
[Obsidian 官方 - **Wiki Links**](https://obsidian.md/zh/help/links){.readmore}
## 嵌入内容
嵌入语法允许你将其他文件资源插入到当前页面中。
### 语法
```md
![[文件名]]
![[文件名#标题]]
![[文件名#标题#子标题]]
```
文件名搜索规则与 [Wiki 链接](#文件名搜索规则) 相同。
::: info 以 `/` 开头或 无路径前缀如 `./` 形式的,从 `public` 目录中加载资源
:::
### 图片嵌入
**语法:**
```md
![[图片]]
![[图片|宽度]]
![[图片|宽度x高度]]
```
支持格式:`jpg``jpeg``png``gif``avif``webp``svg``bmp``ico``tiff``apng``jfif``pjpeg``pjp``xbm`
**示例:**
::: demo markdown title="基础图片" expanded
```md
![[images/custom-hero.jpg]]
```
:::
::: demo markdown title="设置宽度" expanded
```md
![[images/custom-hero.jpg|300]]
```
:::
::: demo markdown title="设置宽度和高度" expanded
```md
![[images/custom-hero.jpg|300x200]]
```
:::
### PDF 嵌入
> [!NOTE]
> PDF 嵌入需要启用 `markdown.pdf` 插件才能正常工作。
**语法:**
```md
![[文档.pdf]]
![[文档.pdf#page=1]] <!-- #page=1 表示第一页 -->
![[文档.pdf#page=1#height=300]] <!-- #page=页码 #height=高度 -->
```
支持格式:`pdf`
---
### 音频嵌入
**语法:**
```md
![[音频文件]]
```
支持格式:`mp3``flac``wav``ogg``opus``webm``acc`
---
### 视频嵌入
> [!NOTE]
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
**语法:**
```md
![[视频文件]]
![[视频文件#height=400]] <!-- 设置视频高度 -->
```
支持格式:`mp4``webm``mov`
---
### 内容片段嵌入
通过 `#标题` 可以嵌入指定标题下的内容片段:
**输入:**
```md
![[我的笔记]]
![[我的笔记#标题一]]
![[我的笔记#标题一#子标题]]
```
[Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore}
[Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore}
## Callout
Callout 是一种用于突出显示重要信息的语法,类似于 VuePress 的 `::: hint` 提示框语法。
### 语法
```md
> [!note]
> 内容
```
**可选标题:**
```md
> [!tip] 自定义标题
> 内容
```
### 类型
Callout 支持以下类型,别名会自动映射到对应的主要类型:
| 类型 | 别名 | 说明 |
| ---- | ---- | ---- |
| `note` | `quote`, `cite` | 笔记、引用 |
| `tip` | `hint` | 技巧、提示 |
| `info` | `todo` | 信息、待办 |
| `success` | `check`, `done` | 成功、完成 |
| `warning` | `question`, `help`, `faq` | 警告、问题、帮助 |
| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | 注意、失败、危险 |
| `important` | `example` | 重要、示例 |
| `details` | `abstract`, `summary`, `tldr` | 详情、摘要 |
### 示例
**基础用法:**
**输入:**
```md
> [!NOTE]
> 这是一个笔记提示框。
```
**输出:**
> [!NOTE]
> 这是一个笔记提示框。
---
**带标题:**
**输入:**
```md
> [!TIP] 实用技巧
> 使用 `pnpm` 可以显著加快依赖安装速度。
```
**输出:**
> [!TIP] 实用技巧
> 使用 `pnpm` 可以显著加快依赖安装速度。
---
**多种类型:**
**输入:**
```md
> [!success]
> 操作成功完成!
>
> [!warning]
> 这是一个警告信息。
>
> [!caution]
> 请谨慎操作,此操作不可撤销。
```
**输出:**
> [!success]
> 操作成功完成!
> [!warning]
> 这是一个警告信息。
> [!caution]
> 请谨慎操作,此操作不可撤销。
---
**Details 类型:**
`details` 类型会渲染为 HTML `<details>` 元素,支持折叠展开:
**输入:**
```md
> [!details]
> 点我展开更多内容
>
> 这是一段隐藏的内容。
```
**输出:**
> [!details]
> 点我展开更多内容
>
> 这是一段隐藏的内容。
[Obsidian 官方 - Callout](https://obsidian.md/zh/help/callouts){.readmore}
## 注释
使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。
### 语法
**行内注释:**
```md
这是一个 %%行内注释%% 示例。
```
**块级注释:**
```md
%%
这是一个块级注释。
可以跨越多行。
%%
```
### 示例
**行内注释:**
**输入:**
```md
这是一个 %%行内注释%% 示例。
```
**输出:**
这是一个 %%行内注释%% 示例。
---
**块级注释:**
**输入:**
```md
注释之前的内容
%%
这是一个块级注释。
可以跨越多行。
%%
注释之后的内容
```
**输出:**
注释之前的内容
%%
这是一个块级注释。
%%
可以跨越多行。
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
## 注意事项
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
- PDF 嵌入需要同时启用 `markdown.pdf` 插件
- 视频嵌入需要同时启用 `markdown.artPlayer` 插件
- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载

View File

@ -6,7 +6,7 @@ permalink: /guide/markdown/qrcode/
badge: 新 badge: 新
--- ---
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume) @[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
## 概述 ## 概述
@ -42,7 +42,7 @@ export default defineUserConfig({
<!-- 基础语法--> <!-- 基础语法-->
@[qrcode](text) @[qrcode](text)
<!-- 添加属性 --> <!-- 添加属性 -->
@[qrcode card svg title="xxx" align="center"](text) @[qrcode card title="xxx" align="center" logo="/plume.png"](text)
``` ```
### 容器语法 ### 容器语法
@ -50,7 +50,7 @@ export default defineUserConfig({
容器语法适用于 `text` 文本较长时,比如 段落,多行文本 等。 容器语法适用于 `text` 文本较长时,比如 段落,多行文本 等。
```md ```md
::: qrcode card svg title="xxx" align="center" ::: qrcode card title="xxx" align="center"
text text
::: :::
``` ```
@ -64,8 +64,13 @@ text
::: field name="card" type="boolean" optional default="false" ::: field name="card" type="boolean" optional default="false"
是否启用卡片样式。 是否启用卡片样式。
::: :::
::: field name="svg" type="boolean" optional default="false" ::: field name="logo" type="string" optional
是否将二维码渲染为 SVG 格式。默认渲染为 PNG 格式。 二维码 logo 图片路径。显示于二维码中心。
仅支持 绝对路径。
:::
::: field name="logoSize" type="number" optional default="0.2"
logo 相对于二维码 大小比例
::: :::
::: field name="title" type="string" optional ::: field name="title" type="string" optional
二维码标题。 二维码标题。
@ -98,6 +103,8 @@ text
更高级别提供更好的抗错能力,但会降低符号的容量。 更高级别提供更好的抗错能力,但会降低符号的容量。
如果二维码符号可能被损坏的几率较低,则可以安全使用低纠错级别,如低或中。 如果二维码符号可能被损坏的几率较低,则可以安全使用低纠错级别,如低或中。
当二维码中包含 logo 时,默认值为 `H`
::: :::
::: field name="version" type="number" optional ::: field name="version" type="number" optional
**二维码版本** **二维码版本**
@ -127,8 +134,23 @@ text
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume) @[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
### 带 logo 的二维码
**输入:**
```md
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
```
**输出:**
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
### 站内的页面路径 ### 站内的页面路径
::: tip 站内页面链接自动添加 logo 图片
:::
**输入:** **输入:**
```md ```md

View File

@ -536,9 +536,9 @@ export default defineThemeConfig({
::: :::
[可以在这里查看 **simple-icons** 所有可用图标](https://icon-sets.iconify.design/simple-icons/){.readmore} [可以在这里查看 **simple-icons** 所有可用图标](https://icon-sets.iconify.design/simple-icons/){.readmore}
如果 **Iconify** 无法满足你的需求,可以传入 `{ svg: string, name?: string }`的格式,使用自定义图标,传入 svg 源码字符串。 如果 **Iconify** 无法满足您的需求,可以传入 `{ svg: string, name?: string }` 格式使用自定义图标,传入 SVG 源码字符串。
## 文章封面配置 ## 文章封面配置
@ -938,9 +938,7 @@ config:
更多自定义配置,请参考 [自定义首页](../custom/home.md)。 更多自定义配置,请参考 [自定义首页](../custom/home.md)。
当使用以上两种方式 将首页配置为 文章列表页后,由于主题默认依然会生成 文章列表页, 当使用以上两种方式将首页配置为文章列表页后,由于主题默认依然会生成文章列表页,这导致存在了重复功能的页面。为此,您可能需要在集合配置中**关闭自动生成博客文章列表页**
这导致存在了重复功能的页面。为此,你可能需要在 集合配置中,
**关闭自动生成博客文章列表页**
(还可以重新修改 分类页/标签页/归档页的链接地址) (还可以重新修改 分类页/标签页/归档页的链接地址)

View File

@ -27,10 +27,10 @@ permalink: /guide/collection/
:::file-tree :::file-tree
- my-site - my-site
- docs \# 源目录 - docs # 源目录
- .vuepress/ - .vuepress/
- … - …
- README.md \# 首页 - README.md # 首页
- package.json - package.json
::: :::
@ -87,7 +87,7 @@ permalink: /guide/collection/
::: :::
`blog` 目录下的 markdown 文章,在 post 集合中读取为文章列表,并生成列表页、分类页、标签页等页面。 `blog` 目录下的 Markdown 文章将被读取为文章列表,并自动生成列表页、分类页、标签页等页面。
- **完成** - **完成**
:::: ::::

View File

@ -12,22 +12,22 @@ permalink: /guide/project-structure/
::: file-tree ::: file-tree
- .git/ - .git/
- **docs** \# 文档源目录 - **docs** # 文档源目录
- .vuepress \# VuePress 配置目录 - .vuepress/ # VuePress 配置目录
- public/ \# 静态资源 - public/ # 静态资源
- client.ts \# 客户端配置(可选) - client.ts # 客户端配置(可选)
- collections.ts \# Collections 配置(可选) - collections.ts # Collections 配置(可选)
- config.ts \# VuePress 主配置 - config.ts # VuePress 主配置
- navbar.ts \# 导航栏配置(可选) - navbar.ts # 导航栏配置(可选)
- plume.config.ts \# 主题配置文件(可选) - plume.config.ts # 主题配置文件(可选)
- demo \# `doc` 类型 collection - demo # `doc` 类型 collection
- foo.md - foo.md
- bar.md - bar.md
- blog \# `post` 类型 collection - blog # `post` 类型 collection
- preview \# 博客分类 - preview # 博客分类
- markdown.md \# 分类文章 - markdown.md # 分类文章
- article.md \# 博客文章 - article.md # 博客文章
- README.md \# 站点首页 - README.md # 站点首页
- … - …
- package.json - package.json
- pnpm-lock.yaml - pnpm-lock.yaml

View File

@ -13,7 +13,7 @@ tags:
侧边栏是文档常见的页面导航方式,可以快速定位到文档内容。 侧边栏是文档常见的页面导航方式,可以快速定位到文档内容。
主题提供了两种方式配置侧边栏,包括: 主题提供了两种方式配置侧边栏
- 通过主题配置的 `sidebar` 选项配置侧边栏 - 通过主题配置的 `sidebar` 选项配置侧边栏
- 在 [类型为 `doc` 的集合](./collection-doc.md) 中配置侧边栏 - 在 [类型为 `doc` 的集合](./collection-doc.md) 中配置侧边栏

View File

@ -8,11 +8,9 @@ tags:
- 快速开始 - 快速开始
--- ---
VuePress 完整支持 [标准 Markdown 语法](../markdown/basic.md),同时允许通过 VuePress 完整支持 [标准 Markdown 语法](../markdown/basic.md),同时允许通过 [YAML](https://dev.to/paulasantamaria/introduction-to-yaml-125f) 格式的 Frontmatter 定义页面元数据(如标题、创建时间等)。
[YAML](https://dev.to/paulasantamaria/introduction-to-yaml-125f)
格式的 Frontmatter 定义页面元数据(如标题、创建时间等)。
此外,主题还提供了丰富的 [Markdown 扩展语法](../markdown/extensions.md)您不仅可以在 Markdown 中直接编写 HTML还能使用 Vue 组件来增强内容表现力。 此外,主题还提供了丰富的 [Markdown 扩展语法](../markdown/extensions.md),您不仅可以在 Markdown 中直接编写 HTML还能使用 Vue 组件来增强内容表现力。
## Frontmatter 页面配置 ## Frontmatter 页面配置
@ -132,14 +130,14 @@ const dir = /\d+\.[\s\S]+/
::: file-tree ::: file-tree
- docs - docs
- blog \# post 类型 collection - blog # post 类型 collection
- 1.前端 - 1.前端
- 1.html/ - 1.html/
- 2.css/ - 2.css/
- 3.javascript/ - 3.javascript/
- 2.后端/ - 2.后端/
- 运维/ - 运维/
- typescript \# doc 类型 collection - typescript # doc 类型 collection
- 1.基础 - 1.基础
- 1.变量.md - 1.变量.md
- 2.类型.md - 2.类型.md

View File

@ -369,8 +369,8 @@ const count = ref(0)
::: :::
:::::: warning :::::: warning
vue demo 容器语法虽然也支持 使用 `.js/ts + css` 的方式来嵌入演示代码, vue demo 容器语法虽然也支持使用 `.js/ts + css` 的方式来嵌入演示代码,
但主题不推荐这样做。因为 样式无法被隔离,这可能导致样式污染。 但主题不推荐这样做。因为样式无法被隔离,这可能导致样式污染。
::::: details 参考示例 ::::: details 参考示例

View File

@ -98,6 +98,8 @@ search: false
| M*e | 2020-12-26 | 10.00 | 最近使用主题弄了个博客,简洁好用。作者回复也快,因为还是学生也就只能微博的支持了 | | M*e | 2020-12-26 | 10.00 | 最近使用主题弄了个博客,简洁好用。作者回复也快,因为还是学生也就只能微博的支持了 |
| *纪 | 2026-01-03 | 9.90 | 新年快乐(,,>‿<,,),感谢佬 | | *纪 | 2026-01-03 | 9.90 | 新年快乐(,,>‿<,,),感谢佬 |
| J*n | 2026-01-22 | 10.00 | 用本开源主题搭了好几个网站了,作者耐心解答,添加合理功能需求,必须支持一下,辛苦了❤️ | | J*n | 2026-01-22 | 10.00 | 用本开源主题搭了好几个网站了,作者耐心解答,添加合理功能需求,必须支持一下,辛苦了❤️ |
| *燧 | 2026-03-14 | 8.88 | 智齿主播,大佬加油 <br>(作者回复:啊?主播?我不是啊) |
| *飞 | 2026-04-25 | 9.90 | - |
</div> </div>

View File

@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"~/themes/*": ["./.vuepress/themes/*"], "~/themes/*": ["./.vuepress/themes/*"],
"~/components/*": ["./.vuepress/themes/components/*"], "~/components/*": ["./.vuepress/themes/components/*"],

View File

@ -7,8 +7,11 @@ export default config({
}, },
ignores: [ ignores: [
'lib', 'lib',
'skills',
'docs/snippet/code-block.snippet.md', 'docs/snippet/code-block.snippet.md',
'docs/snippet/whitespace.snippet.md', 'docs/snippet/whitespace.snippet.md',
'docs/en/guide/markdown/obsidian.md',
'docs/guide/markdown/obsidian.md',
], ],
globals: { globals: {
__VUEPRESS_VERSION__: 'readonly', __VUEPRESS_VERSION__: 'readonly',

View File

@ -1,9 +1,9 @@
{ {
"name": "vuepress-theme-plume-monorepo", "name": "vuepress-theme-plume-monorepo",
"type": "module", "type": "module",
"version": "1.0.0-rc.192", "version": "1.0.0-rc.198",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3", "packageManager": "pnpm@10.33.2",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)", "author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [
@ -18,9 +18,9 @@
"pnpm": ">=9" "pnpm": ">=9"
}, },
"scripts": { "scripts": {
"build": "pnpm clean && pnpm build:package", "build": "pnpm run clean && pnpm build:package",
"build:package": "pnpm -r --stream build", "build:package": "pnpm -r --stream build",
"clean": "pnpm -r --stream clean", "clean": "pnpm -r --stream run clean",
"dev": "pnpm --stream '/(dev:package|docs:dev)/'", "dev": "pnpm --stream '/(dev:package|docs:dev)/'",
"dev:package": "pnpm --parallel dev", "dev:package": "pnpm --parallel dev",
"docs:dev": "wait-on -d 100 theme/lib/node/index.js && pnpm -F=docs docs:dev", "docs:dev": "wait-on -d 100 theme/lib/node/index.js && pnpm -F=docs docs:dev",
@ -33,7 +33,7 @@
"lint:css": "stylelint **/*.{css,vue}", "lint:css": "stylelint **/*.{css,vue}",
"test": "cross-env TZ=Etc/UTC vitest --coverage", "test": "cross-env TZ=Etc/UTC vitest --coverage",
"prepare": "husky", "prepare": "husky",
"release:changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", "release:changelog": "conventional-changelog -p angular",
"release:check": "pnpm lint && pnpm build", "release:check": "pnpm lint && pnpm build",
"release:sync": "node scripts/mirror-sync.mjs", "release:sync": "node scripts/mirror-sync.mjs",
"release:publish": "pnpm -r publish --tag latest", "release:publish": "pnpm -r publish --tag latest",
@ -57,7 +57,8 @@
"@vitest/coverage-v8": "catalog:dev", "@vitest/coverage-v8": "catalog:dev",
"bumpp": "catalog:dev", "bumpp": "catalog:dev",
"commitizen": "catalog:dev", "commitizen": "catalog:dev",
"conventional-changelog-cli": "catalog:dev", "conventional-changelog": "catalog:dev",
"conventional-changelog-angular": "catalog:dev",
"cpx2": "catalog:dev", "cpx2": "catalog:dev",
"cross-env": "catalog:dev", "cross-env": "catalog:dev",
"cz-conventional-changelog": "catalog:dev", "cz-conventional-changelog": "catalog:dev",
@ -85,14 +86,23 @@
} }
}, },
"resolutions": { "resolutions": {
"@bufbuild/protobuf": "^2.11.0", "@bufbuild/protobuf": "^2.12.0",
"@eslint-community/eslint-utils": "catalog:peer", "@eslint-community/eslint-utils": "catalog:peer",
"@shikijs/core": "^4.0.2",
"@shikijs/twoslash": "^4.0.2",
"@typescript-eslint/types": "catalog:peer", "@typescript-eslint/types": "catalog:peer",
"@typescript-eslint/utils": "catalog:peer", "@typescript-eslint/utils": "catalog:peer",
"baseline-browser-mapping": "^2.10.0", "@xmldom/xmldom": ">=0.9.10",
"baseline-browser-mapping": "^2.10.22",
"chokidar": "catalog:prod", "chokidar": "catalog:prod",
"dompurify": ">=3.4.1",
"esbuild": "catalog:prod", "esbuild": "catalog:prod",
"follow-redirects": ">=1.16.0",
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1",
"sass-embedded": "catalog:peer", "sass-embedded": "catalog:peer",
"shiki": "^4.0.2",
"tmp": ">=0.2.5",
"vite": "catalog:dev", "vite": "catalog:dev",
"vue-router": "catalog:prod" "vue-router": "catalog:prod"
}, },

View File

@ -1,7 +1,7 @@
{ {
"name": "@vuepress-plume/plugin-fonts", "name": "@vuepress-plume/plugin-fonts",
"type": "module", "type": "module",
"version": "1.0.0-rc.192", "version": "1.0.0-rc.198",
"description": "The Plugin for VuePress 2 - fonts", "description": "The Plugin for VuePress 2 - fonts",
"author": "pengzhanbo <volodymyr@foxmail.com>", "author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT", "license": "MIT",
@ -30,7 +30,7 @@
"build": "pnpm run tsdown && pnpm run copy", "build": "pnpm run tsdown && pnpm run copy",
"clean": "rimraf --glob ./lib", "clean": "rimraf --glob ./lib",
"copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png,woff2}\" lib", "copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png,woff2}\" lib",
"tsdown": "tsdown" "tsdown": "tsdown --config-loader unrun"
}, },
"peerDependencies": { "peerDependencies": {
"vuepress": "catalog:vuepress" "vuepress": "catalog:vuepress"

View File

@ -1,7 +1,5 @@
import { defineConfig } from 'tsdown' import { defineConfig, type UserConfig } from 'tsdown'
import { argv } from '../../scripts/tsdown-args.mjs' import { argv } from '../../scripts/tsdown-args'
/** @import {Options} from 'tsdown' */
const clientExternal = [ const clientExternal = [
/.*\.vue$/, /.*\.vue$/,
@ -9,15 +7,13 @@ const clientExternal = [
] ]
export default defineConfig(() => { export default defineConfig(() => {
/** @type {Options} */ const DEFAULT_OPTIONS: UserConfig = {
const DEFAULT_OPTIONS = {
dts: true, dts: true,
sourcemap: false, sourcemap: false,
format: 'esm', format: 'esm',
fixedExtension: false, fixedExtension: false,
} }
/** @type {Options[]} */ const options: UserConfig[] = []
const options = []
if (argv.node) { if (argv.node) {
options.push({ options.push({
@ -36,7 +32,7 @@ export default defineConfig(() => {
entry: ['./src/client/config.ts'], entry: ['./src/client/config.ts'],
outDir: './lib/client', outDir: './lib/client',
dts: false, dts: false,
external: clientExternal, deps: { neverBundle: clientExternal },
}, },
]) ])
} }

View File

@ -72,7 +72,7 @@ describe('fileTreePlugin', () => {
- client - client
- components - components
- **Navbar.vue** - **Navbar.vue**
- index.ts \# comment - index.ts # comment
- node - node
- index.ts - index.ts
- .gitignore - .gitignore

View File

@ -0,0 +1,917 @@
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { calloutPlugin } from '../src/node/obsidian/callouts.js'
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
return {
filePathRelative,
base: '/',
links: [],
importedFiles: [],
}
}
function createMarkdown() {
return new MarkdownIt({ html: true })
}
describe('calloutPlugin', () => {
// ==================== Primary Callout Types ====================
describe('primary callout types', () => {
const types = ['note', 'tip', 'info', 'success', 'warning', 'caution', 'important', 'details']
types.forEach((type) => {
it(`should render ${type} callout`, () => {
const md = createMarkdown().use(calloutPlugin)
// Callout format: >[!type] title on same line, content on continuation lines with >
const result = md.render(`>[!${type}]\n>\n> Content here.`)
expect(result).toContain(`hint-container ${type}`)
expect(result).toContain('Content here')
})
})
it('should render note with quote and cite aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const quoteResult = md.render('>[!quote]\n>\n> Content.')
expect(quoteResult).toContain('hint-container note')
const citeResult = md.render('>[!cite]\n>\n> Content.')
expect(citeResult).toContain('hint-container note')
})
it('should render tip with hint alias', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!hint]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should render info with todo alias', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!todo]\n>\n> Content.')
expect(result).toContain('hint-container info')
})
it('should render success with check and done aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const checkResult = md.render('>[!check]\n>\n> Content.')
expect(checkResult).toContain('hint-container success')
const doneResult = md.render('>[!done]\n>\n> Content.')
expect(doneResult).toContain('hint-container success')
})
it('should render warning with question, help, and faq aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const questionResult = md.render('>[!question]\n>\n> Content.')
expect(questionResult).toContain('hint-container warning')
const helpResult = md.render('>[!help]\n>\n> Content.')
expect(helpResult).toContain('hint-container warning')
const faqResult = md.render('>[!faq]\n>\n> Content.')
expect(faqResult).toContain('hint-container warning')
})
it('should render caution with multiple aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const aliases = ['attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug']
aliases.forEach((alias) => {
const result = md.render(`>[!${alias}]\n>\n> Content.`)
expect(result).toContain('hint-container caution')
})
})
it('should render important with example alias', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!example]\n>\n> Content.')
expect(result).toContain('hint-container important')
})
it('should render details with abstract, summary, and tldr aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const abstractResult = md.render('>[!abstract]\n>\n> Content.')
expect(abstractResult).toContain('hint-container details')
const summaryResult = md.render('>[!summary]\n>\n> Content.')
expect(summaryResult).toContain('hint-container details')
const tldrResult = md.render('>[!tldr]\n>\n> Content.')
expect(tldrResult).toContain('hint-container details')
})
})
// ==================== Case Insensitivity ====================
describe('case insensitivity', () => {
it('should handle uppercase type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!TIP]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should handle mixed case type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!Tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should handle lowercase type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
})
// ==================== Title Handling ====================
describe('title handling', () => {
it('should render custom title text', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] Custom Title\n>\n> Content.')
expect(result).toContain('Custom Title')
})
it('should render title with + prefix', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] + Custom Title\n>\n> Content.')
expect(result).toContain('Custom Title')
})
it('should render title with - prefix', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] - Custom Title\n>\n> Content.')
expect(result).toContain('Custom Title')
})
it('should use default capitalized type when no title', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('Tip')
})
it('should render empty title with default', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] \n>\n> Content.')
expect(result).toContain('Tip')
})
})
// ==================== Content Rendering ====================
describe('content rendering', () => {
it('should render single line content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Single line content.')
expect(result).toContain('Single line content')
})
it('should render multi-line content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> First paragraph.
>
> Second paragraph.`)
expect(result).toContain('First paragraph')
expect(result).toContain('Second paragraph')
})
it('should render nested list within callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> - Item 1
> - Item 2
> - Item 3`)
expect(result).toContain('Item 1')
expect(result).toContain('Item 2')
expect(result).toContain('Item 3')
})
it('should render heading within callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> ### Nested Heading
>
> Content after heading.`)
expect(result).toContain('Nested Heading')
expect(result).toContain('Content after heading')
})
it('should render code block within callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> \`\`\`js
> const x = 1;
> \`\`\``)
expect(result).toContain('const x = 1')
})
it('should parse content after callout correctly', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Callout content.
>
After callout paragraph.
## Heading after
More content.`)
expect(result).toContain('Callout content')
expect(result).toContain('After callout paragraph')
expect(result).toContain('Heading after')
})
})
// ==================== Syntax Variations ====================
describe('syntax variations', () => {
it('should parse without space after >', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should parse with space after >', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('> [!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should parse with multiple spaces', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('> [!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should parse with tab after >', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>\t[!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should handle tab with space alignment', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(' >\t [!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
})
// ==================== Block Parsing Edge Cases ====================
describe('block parsing edge cases', () => {
it('should terminate on empty line outside callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content.
>
> More content.
After callout.`)
expect(result).toContain('Content')
expect(result).toContain('More content')
expect(result).toContain('After callout')
})
it('should terminate on outdented content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content.
outdented line`)
expect(result).toContain('Content')
expect(result).toContain('outdented line')
})
it('should handle outdented line as block terminator (line 265)', () => {
// When the content line is outdented (sCount < blkIndent), the callout ends
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. List item
>[!tip]
>
> Content.
2. Next item`)
expect(result).toContain('Content')
expect(result).toContain('Next item')
expect(result).toContain('hint-container tip')
})
it('should terminate on horizontal rule', () => {
const md = createMarkdown().use(calloutPlugin)
// Using *** instead of - - - to ensure it's recognized as horizontal rule
const result = md.render(`>[!tip]
>
> Content.
>
> ***`)
expect(result).toContain('Content')
})
it('should terminate when terminator rule matches (lines 280-281)', () => {
// The terminator rule for blockquote will match when the callout is properly terminated
// by another blockquote-like structure
const md = createMarkdown().use(calloutPlugin)
// After the horizontal rule, the content below should be separate
const result = md.render(`>[!tip]
>
> Content.
---
After horizontal rule.`)
expect(result).toContain('Content')
expect(result).toContain('After horizontal rule')
})
it('should handle list terminator correctly (lines 280-281)', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Callout content.
>
> 1. Ordered list inside`)
expect(result).toContain('Callout content')
expect(result).toContain('Ordered list inside')
})
it('should terminate on list item', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content.
>
> 1. Ordered item`)
expect(result).toContain('Content')
})
it('should handle continuation after blockquote in list', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. List item
>[!tip]
>
> Content in callout.
>
> More content.
2. Next list item`)
expect(result).toContain('Content in callout')
expect(result).toContain('More content')
expect(result).toContain('Next list item')
})
it('should restore state correctly when blkIndent !== 0 (lines 290-304)', () => {
// When callout is in a list item (non-zero blkIndent) and is terminated
// by another block, the blkIndent adjustment should be restored
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. List item
>[!tip]
>
> Callout content.
>
> ---`)
expect(result).toContain('Callout content')
expect(result).toContain('hint-container tip')
})
it('should handle nested blockquote in callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> > Nested quote
>
> Content after nested.`)
expect(result).toContain('Nested quote')
expect(result).toContain('Content after nested')
})
it('should handle callout with proper terminator restoration', () => {
// Test for lines 290-304: blkIndent restoration when terminated by other block
// This requires the callout to be inside a list with non-zero blkIndent
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. List item
>[!tip]
>
> Callout content.
>
> More content.
>
> - - -
2. Next item`)
expect(result).toContain('Callout content')
expect(result).toContain('More content')
expect(result).toContain('hint-container tip')
expect(result).toContain('Next item')
})
})
// ==================== Invalid Syntax ====================
describe('invalid syntax', () => {
it('should not parse unknown type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!unknown]\n>\n> Content.')
expect(result).not.toContain('hint-container')
expect(result).toContain('Content')
})
it('should not parse empty type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[]\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should not parse incomplete syntax without closing bracket', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should not parse without opening bracket', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>!tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should not parse without > marker', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('[!tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should not parse when indented more than 3 spaces (becomes code)', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(' >[!tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
expect(result).toContain('<code')
})
it('should return false when sCount - blkIndent >= 4 (line 44)', () => {
// Line 44: if sCount - blkIndent >= 4, return false
// This would happen when a line is deeply indented beyond the block indent
// In practice, blkIndent tracks sCount in list contexts, making this hard to trigger
// We test with a deeply indented block that exceeds normal block processing
const md = createMarkdown().use(calloutPlugin)
// This scenario exercises the code path even if the exact condition is hard to isolate
const result = md.render('> [!tip]\n>\n> Content.')
// With 5 spaces after >, offset-initial=4 triggers line 96 first
// But the overall block parsing exercises related code paths
expect(result).not.toContain('hint-container')
})
it('should not parse type only without brackets', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should not parse empty callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]')
expect(result).not.toContain('hint-container')
})
it('should not parse callout with only empty continuation lines', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
>`)
expect(result).not.toContain('hint-container')
})
it('should return false when offset - initial >= 4 (line 96)', () => {
// Line 96: offset - initial >= 4 means 4+ spaces after > before the callout type
// > [!tip] has 5 spaces after >, so offset - initial = 4 >= 4, returns false
// This causes it to be treated as a code block within blockquote
const md = createMarkdown().use(calloutPlugin)
const result = md.render('> [!tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
// It should be treated as blockquote with code-like content
expect(result).toContain('<code')
expect(result).toContain('[!tip]')
})
})
// ==================== Special Type: details ====================
describe('details type rendering', () => {
it('should render details as details element', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!details]\n>\n> Content.')
expect(result).toContain('<details')
expect(result).toContain('</details>')
expect(result).toContain('<summary')
expect(result).toContain('</summary>')
})
it('should not use div for details type', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!details]\n>\n> Content.')
expect(result).not.toContain('<div class="hint-container details"')
})
it('should render summary tag for details title', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!details] Summary Title\n>\n> Content.')
expect(result).toContain('<summary')
expect(result).toContain('Summary Title')
})
it('should render details with other aliases', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!abstract]\n>\n> Content.')
expect(result).toContain('<details')
expect(result).toContain('<summary')
})
})
// ==================== Default Rendering Structure ====================
describe('rendering structure', () => {
it('should render alert_open with correct class', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('hint-container tip')
})
it('should render alert_title with correct class', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] Title\n>\n> Content.')
expect(result).toContain('hint-container-title')
})
it('should render opening and closing tags', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('<div')
expect(result).toContain('</div>')
})
it('should render hint-container class', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('hint-container')
})
})
// ==================== Locale Support ====================
describe('locale support', () => {
it('should use default type name when no locale match', () => {
const md = createMarkdown().use(calloutPlugin, {
locales: {},
})
const result = md.render('>[!tip]\n>\n> Content.')
expect(result).toContain('Tip')
})
it('should use custom locale title when provided', () => {
const md = createMarkdown().use(calloutPlugin, {
locales: {
'/': {
tip: 'Custom Tip Title',
},
},
})
const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('/'))
expect(result).toContain('Custom Tip Title')
})
it('should use locale for specific path', () => {
const md = createMarkdown().use(calloutPlugin, {
locales: {
'/zh/': {
tip: '提示',
},
},
})
const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md'))
expect(result).toContain('提示')
})
it('should prefer custom locale over default', () => {
const md = createMarkdown().use(calloutPlugin, {
locales: {
'/': {
tip: 'Default Tip',
},
'/zh/': {
tip: '中文提示',
},
},
})
const defaultResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('guide.md'))
expect(defaultResult).toContain('Default Tip')
const zhResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md'))
expect(zhResult).toContain('中文提示')
})
it('should handle locale without matching type', () => {
const md = createMarkdown().use(calloutPlugin, {
locales: {
'/': {
note: 'Note Title',
},
},
})
const result = md.render('>[!tip]\n>\n> Content.')
// Should still use capitalized type as fallback
expect(result).toContain('Tip')
})
})
// ==================== Edge Cases ====================
describe('edge cases', () => {
it('should handle callout at beginning of document', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> First content.
Second paragraph.`)
expect(result).toContain('First content')
expect(result).toContain('Second paragraph')
})
it('should handle multiple callouts in sequence', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Tip content.
>[!warning]
>
> Warning content.
>[!note]
>
> Note content.`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Tip content')
expect(result).toContain('hint-container warning')
expect(result).toContain('Warning content')
expect(result).toContain('hint-container note')
expect(result).toContain('Note content')
})
it('should handle empty lines within callout', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Line 1.
>
>
> Line 2.`)
expect(result).toContain('Line 1')
expect(result).toContain('Line 2')
})
it('should handle adjacent callouts without blank line', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> First.
>[!note]
>
> Second.`)
expect(result).toContain('First')
expect(result).toContain('Second')
})
it('should not interfere with regular blockquotes', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`> Regular blockquote
>
> Another line.`)
expect(result).not.toContain('hint-container')
expect(result).toContain('Regular blockquote')
})
it('should handle indented callout in list', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`- List item
>[!tip]
>
> Indented callout.`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Indented callout')
})
it('should handle unicode in title', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] 中文标题\n>\n> Content.')
expect(result).toContain('中文标题')
})
it('should handle emoji in title', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip] 🚀 Launch\n>\n> Content.')
expect(result).toContain('🚀')
expect(result).toContain('Launch')
})
})
// ==================== Inline Content Rendering ====================
describe('inline content rendering', () => {
it('should render inline code in content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Use `code` inline.')
expect(result).toContain('<code>code</code>')
})
it('should render links in content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> Check [this](https://example.com).')
expect(result).toContain('<a href="https://example.com"')
})
it('should render emphasis in content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> This is *italic* and **bold**.')
expect(result).toContain('<em>italic</em>')
expect(result).toContain('<strong>bold</strong>')
})
it('should render strikethrough in content', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render('>[!tip]\n>\n> ~~Deleted~~ text.')
expect(result).toContain('<s>Deleted</s>')
})
})
// ==================== Code Block Type Detection ====================
describe('code block type detection', () => {
it('should detect as code block when indented 4+ spaces', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(` >[!tip] Title
>
> Content.`)
expect(result).toContain('<code')
expect(result).not.toContain('hint-container')
})
})
describe('sCount - blkIndent >= 4', () => {
it('should return false when deeply indented and code rule is disabled', () => {
const md = createMarkdown().use(calloutPlugin)
md.block.ruler.disable('code')
const result = md.render(' >[!tip]\n>\n> Content.')
expect(result).not.toContain('hint-container')
})
it('should return false when deeply indented inside list with code rule disabled', () => {
const md = createMarkdown().use(calloutPlugin)
md.block.ruler.disable('code')
const result = md.render(`- Item
>[!tip]
>
> Content`)
expect(result).not.toContain('hint-container tip')
})
})
describe('isOutdented break inside list', () => {
it('should break when body line is outdented from list context', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`- Item
>[!tip]
>
> Content
Outdented line`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Content')
expect(result).toContain('Outdented line')
})
it('should break when callout body reaches line outside list indent', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. Item
>[!warning]
>
> Content
Outside list`)
expect(result).toContain('hint-container warning')
expect(result).toContain('Content')
expect(result).toContain('Outside list')
})
})
describe('terminator rule matches', () => {
it('should terminate callout when horizontal rule follows without blank line', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content
---`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Content')
expect(result).toContain('<hr')
})
it('should terminate callout when ATX heading follows without blank line', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content
## Heading`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Content')
expect(result).toContain('Heading')
})
it('should terminate callout when fence block follows without blank line', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`>[!tip]
>
> Content
\`\`\`js
code
\`\`\``)
expect(result).toContain('hint-container tip')
expect(result).toContain('Content')
expect(result).toContain('<code')
})
})
describe('blkIndent !== 0 when terminated', () => {
it('should adjust sCount when callout in list is terminated by hr', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`- Item
>[!tip]
>
> Content
---`)
expect(result).toContain('hint-container tip')
expect(result).toContain('Content')
})
it('should adjust sCount when callout in ordered list is terminated by hr', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`1. Item
>[!caution]
>
> Content
---`)
expect(result).toContain('hint-container caution')
expect(result).toContain('Content')
})
it('should handle callout in list terminated by fence', () => {
const md = createMarkdown().use(calloutPlugin)
const result = md.render(`- Item
>[!note]
>
> Content
\`\`\`
code
\`\`\``)
expect(result).toContain('hint-container note')
expect(result).toContain('Content')
})
})
})

View File

@ -0,0 +1,72 @@
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { commentPlugin } from '../src/node/obsidian/comment.js'
describe('commentPlugin', () => {
const md = new MarkdownIt().use(commentPlugin)
it('should ignore inline comment', () => {
const result = md.render('This is %%inline comment%% text.')
expect(result).not.toContain('inline comment')
expect(result).toContain('This is text.')
})
it('should ignore block comment', () => {
const result = md.render(`%% block comment %%
more text`)
expect(result).not.toContain('block comment')
expect(result).toContain('more text')
})
it('should handle multi-line block comment', () => {
const result = md.render(`%%
This is a block comment
spanning multiple lines
%%
This is after.`)
expect(result).not.toContain('block comment')
expect(result).not.toContain('spanning multiple lines')
expect(result).toContain('This is after.')
})
it('should handle comment at start of line', () => {
const result = md.render('%%comment%% start')
expect(result).toContain('start')
expect(result).not.toContain('comment')
})
it('should handle empty comment', () => {
const result = md.render('%%%%')
expect(result).toBeDefined()
})
it('should not treat single % as comment', () => {
const result = md.render('50% off')
expect(result).toContain('50%')
expect(result).not.toContain('%%')
})
it('should handle nested content after comment', () => {
const result = md.render(`%%
block comment
%%
## Heading
paragraph`)
expect(result).toContain('<h2')
expect(result).toContain('Heading')
expect(result).not.toContain('block comment')
})
it('should not parse incomplete comment without closing', () => {
const result = md.render('%%incomplete')
expect(result).toContain('%%incomplete')
})
it('should not parse single opening percent', () => {
const result = md.render('% test')
expect(result).toContain('% test')
})
})

View File

@ -0,0 +1,752 @@
import type { App } from 'vuepress'
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
import { initPagePaths } from '../src/node/obsidian/findFirstPage.js'
const mockGlobSync = vi.fn()
const mockReadFileSync = vi.fn()
vi.mock('vuepress/utils', () => ({
tinyglobby: {
globSync: (...args: unknown[]) => mockGlobSync(...args),
},
fs: {
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
},
path: {
dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/') || '.'),
extname: vi.fn((p: string) => {
const i = p.lastIndexOf('.')
return i > 0 ? p.slice(i) : ''
}),
join: vi.fn((...args: string[]) => args.join('/')),
},
hash: vi.fn((s: string) => `hash_${s.length}`),
}))
vi.mock('gray-matter', () => ({
default: vi.fn((content: string) => ({
content: content.replace(/^---[\s\S]*?---\n?/, ''),
data: {},
})),
}))
vi.mock('@vuepress/helper', () => ({
removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')),
}))
vi.mock('@vuepress/shared', () => ({
ensureLeadingSlash: vi.fn((p: string) => (p[0] === '/' ? p : `/${p}`)),
isLinkHttp: vi.fn((p: string) => p.startsWith('http://') || p.startsWith('https://')),
}))
vi.mock('../src/node/enhance/links.js', () => ({
resolvePaths: vi.fn((rawPath: string) => ({
absolutePath: `/${rawPath}`,
relativePath: rawPath,
})),
}))
vi.mock('../src/node/utils/slugify.js', () => ({
slugify: vi.fn((s: string) => s.toLowerCase().replace(/\s+/g, '-')),
}))
vi.mock('../src/node/utils/cleanMarkdownEnv.js', () => ({
cleanMarkdownEnv: vi.fn((env: MarkdownEnv) => env),
}))
function createMockApp(pages: App['pages'] = []): App {
return {
pages,
options: {
pagePatterns: ['**/*.md'],
},
dir: {
source: () => '/source',
},
} as unknown as App
}
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
return {
filePathRelative,
base: '/',
links: [],
importedFiles: [],
}
}
function createMarkdownWithMockRules() {
return MarkdownIt({ html: true }).use((md) => {
md.block.ruler.before('code', 'import_code', () => false)
md.renderer.rules.import_code = () => ''
})
}
describe('embedLinkPlugin', () => {
beforeEach(() => {
mockGlobSync.mockReset()
mockReadFileSync.mockReset()
})
// ==================== Asset Embedding ====================
describe('asset embedding', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should render image embed', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[image.png]]')
expect(result).toContain('<img')
expect(result).toContain('src="/image.png"')
expect(result).toContain('alt="image.png"')
})
it('should render image with width setting', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[image.png|300]]')
expect(result).toContain('<img')
expect(result).toContain('width: 300px')
})
it('should render image with width x height setting', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[image.png|300x200]]')
expect(result).toContain('<img')
expect(result).toContain('width: 300px')
expect(result).toContain('height: 200px')
})
it('should render audio embed', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[audio.mp3]]')
expect(result).toContain('<audio')
expect(result).toContain('<source')
expect(result).toContain('src="/audio.mp3"')
})
it('should render video embed with artPlayer', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[video.mp4]]')
expect(result).toContain('<ArtPlayer')
expect(result).toContain('src="/video.mp4"')
expect(result).toContain('type="mp4"')
})
it('should render pdf embed', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[document.pdf]]')
expect(result).toContain('<PDFViewer')
expect(result).toContain('src="/document.pdf"')
})
it('should render pdf with page hash', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[document.pdf#page=1]]')
expect(result).toContain('page="1"')
})
})
// ==================== External Links ====================
describe('external links', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should render external http link as anchor', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[https://example.com/file]]')
expect(result).toContain('<a')
expect(result).toContain('href="https://example.com/file"')
expect(result).toContain('target="_blank"')
})
it('should return http links as-is for assets', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[https://example.com/image.png]]')
expect(result).toContain('src="https://example.com/image.png"')
})
})
// ==================== Path Resolution ====================
describe('path resolution', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should return relative paths starting with dot as-is', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[./image.png]]', createMockEnv('docs/page.md'))
expect(result).toContain('src="./image.png"')
})
it('should return absolute paths starting with slash as-is', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[/images/cover.jpg]]')
expect(result).toContain('src="/images/cover.jpg"')
})
it('should prepend slash to relative paths without dot', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[image.png]]')
expect(result).toContain('src="/image.png"')
})
it('should ignore non-image with unsupported extension as link', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[file.unknown]]')
expect(result).toContain('<a')
expect(result).toContain('href="file.unknown"')
})
})
// ==================== Markdown Embedding ====================
describe('markdown file embedding', () => {
const guideContent = `---
title: Guide
---
# Introduction
This is intro content.
## Getting Started
Steps for getting started.
## Advanced
Advanced content.
`
beforeEach(() => {
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(guideContent)
const app = createMockApp()
initPagePaths(app)
})
it('should embed entire markdown file when no heading specified', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('![[guide]]', env)
expect(result).toContain('Introduction')
expect(result).toContain('intro content')
expect(result).toContain('Getting Started')
expect(result).toContain('Steps for getting started')
})
it('should embed content under specific heading', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('![[guide#Getting Started]]', env)
expect(result).toContain('Steps for getting started')
expect(result).not.toContain('Advanced content')
expect(result).not.toContain('#') // no heading markers
})
it('should embed nested heading content', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('![[guide#Introduction#Getting Started]]', env)
expect(result).toContain('Steps for getting started')
expect(result).not.toContain('Advanced content')
})
it('should track imported files in env', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
md.render('![[guide]]', env)
expect(env.importedFiles).toContain('guide.md')
})
})
// ==================== Markdown Not Found ====================
describe('when page does not exist', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should render markdown file embed as link', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[nonexistent.md]]')
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent.md"')
})
it('should render with heading anchor', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[nonexistent#section]]')
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent#section"')
expect(result).toContain('target="_blank"')
})
})
// ==================== Container Syntax ====================
describe('container syntax preservation', () => {
const contentWithContainers = `---
title: Test
---
# Section
::: info
This is a container
:::
Regular content.
`
beforeEach(() => {
mockGlobSync.mockReturnValue(['test.md'])
mockReadFileSync.mockReturnValue(contentWithContainers)
const app = createMockApp()
initPagePaths(app)
})
it('should preserve container syntax when embedding', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('![[test#Section]]', env)
expect(result).toContain('::: info')
expect(result).toContain('This is a container')
expect(result).toContain('Regular content')
})
})
// ==================== Error Handling ====================
describe('error handling', () => {
it('should return empty string when file read fails', () => {
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockImplementation(() => {
throw new Error('ENOENT')
})
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, app)
const env = createMockEnv()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = md.render('![[guide]]', env)
expect(result).toBe('')
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('can not read file'))
warnSpy.mockRestore()
})
it('should return empty string when file has only frontmatter', () => {
// gray-matter will extract empty content when file has only frontmatter
mockGlobSync.mockReturnValue(['empty.md'])
mockReadFileSync.mockReturnValue(`---
title: Empty
---
`)
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, app)
const env = createMockEnv()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = md.render('![[empty]]', env)
expect(result).toBe('')
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('is empty'))
warnSpy.mockRestore()
})
})
// ==================== Heading Search Edge Cases ====================
describe('heading search edge cases', () => {
it('should find heading when same text appears at different nesting levels', () => {
const content = `# Title
## Summary
Summary content.
## Details
### Summary
Nested summary under details.
## Conclusion
Conclusion content.`
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(content)
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
// Should match first "Summary" at level 2
const result = md.render('![[guide#Summary]]', env)
expect(result).toContain('Summary content.')
expect(result).not.toContain('Nested summary')
})
it('should return empty string when heading not found', () => {
const content = `# Title
## Section
Content.`
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(content)
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = md.render('![[guide#Nonexistent]]', env)
expect(result).toBe('')
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No heading found'))
warnSpy.mockRestore()
})
it('should reset search when encountering same-level heading with different text', () => {
const content = `# A
## B
B content.
## C
C content.`
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(content)
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
// Searching for A > B > C, but C is at same level as B, not nested under it
const result = md.render('![[guide#A#B#C]]', env)
expect(result).toBe('')
})
it('should reset headingPointer when first heading reappears at shallower level', () => {
// Structure: # A, ## B, ## A
// When searching for A > B > A, after finding A at level 1 and B at level 2,
// we encounter A again at level 2 which is <= currentLevel and matches headings[0]
const content = `# A
## B
B content.
## A
A content again.`
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(content)
const app = createMockApp()
initPagePaths(app)
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
// Searching for A > B > A
// After finding A (level 1) and B (level 2), we find A at level 2
// level 2 <= currentLevel 2 is true, and A === headings[0], so we reset
const result = md.render('![[guide#A#B#A]]', env)
expect(result).toBe('')
})
})
// ==================== Edge Cases ====================
describe('edge cases', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should not parse embed not ending with ]]', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[image.png]')
expect(result).toContain('![[image.png]')
})
it('should not parse empty embed link', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[]]')
expect(result).toContain('![[]]')
})
})
// ==================== Inline Embed Link ====================
describe('inline embed link', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
const app = createMockApp()
initPagePaths(app)
})
it('should parse inline image embed within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Here is an image ![[photo.png]] in text.')
expect(result).toContain('<img')
expect(result).toContain('src="/photo.png"')
})
it('should parse inline audio embed within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Listen to ![[music.mp3]] this.')
expect(result).toContain('<audio')
expect(result).toContain('<source')
})
it('should parse inline video embed within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Watch ![[clip.mp4]] this video.')
expect(result).toContain('<ArtPlayer')
})
it('should parse inline pdf embed within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('See ![[doc.pdf]] for details.')
expect(result).toContain('<PDFViewer')
})
it('should parse inline external http link within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Check ![[https://example.com/link]] out.')
expect(result).toContain('<a')
expect(result).toContain('href="https://example.com/link"')
})
it('should parse inline embed with settings within text', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Image ![[photo.png|400x300]] here.')
expect(result).toContain('<img')
expect(result).toContain('width: 400px')
expect(result).toContain('height: 300px')
})
it('should not parse inline embed without closing', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Here ![[image.png] is text.')
expect(result).toContain('![[image.png]')
})
it('should handle inline embed with empty content', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('Here ![[]] is text.')
// Empty embed is parsed and rendered as an external link
expect(result).toContain('<a')
expect(result).toContain('href="/"')
})
})
// ==================== Inline Markdown Page Embed ====================
describe('inline markdown page embed (VPLink)', () => {
const guideContent = `---
title: Guide
---
# Introduction
This is intro content.
## Getting Started
Steps for getting started.
`
beforeEach(() => {
mockGlobSync.mockReturnValue(['guide.md'])
mockReadFileSync.mockReturnValue(guideContent)
const app = createMockApp()
initPagePaths(app)
})
it('should render inline markdown page embed as VPLink', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('See ![[guide]] for details.', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/guide.md"')
})
it('should render inline markdown page embed with anchor as VPLink', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('See ![[guide#Introduction]] for details.', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/guide.md#introduction"')
})
it('should render inline markdown page embed with settings as VPLink', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('See ![[guide|Custom Text]] for details.', env)
expect(result).toContain('<VPLink')
expect(result).toContain('Custom Text')
})
it('should render inline markdown page embed with hashes as VPLink using after-text template', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('See ![[guide#Introduction#Getting Started]] for details.', env)
expect(result).toContain('<VPLink')
// Anchor slug is appended to href, after-text shows the full path hierarchy
expect(result).toContain('href="/guide.md#getting-started"')
expect(result).toContain('template #after-text')
expect(result).toContain('&gt; Introduction &gt; Getting Started')
})
it('should track links in env when rendering inline page embed', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
md.render('See ![[guide]] for details.', env)
expect(env.links).toContainEqual(
expect.objectContaining({
raw: 'guide.md',
}),
)
})
it('should render inline embed with relative path as external link when not found', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv('docs/page.md')
const result = md.render('See ![[./guide]] for details.', env)
// ./guide doesn't resolve to a page in findFirstPage, so renders as external link
expect(result).toContain('<a')
expect(result).toContain('href="/docs/./guide"')
})
it('should render inline nonexistent page embed as external link', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('See ![[nonexistent]] for details.', env)
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent"')
expect(result).toContain('target="_blank"')
})
})
// ==================== Inline External Resource with Anchor ====================
describe('inline external resource with anchor', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should render inline external link with anchor', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('Check ![[https://example.com/page#section]] out.', env)
expect(result).toContain('<a')
// The #section anchor is parsed but since the URL doesn't have a recognized extension,
// it's treated as an external link without proper anchor handling
expect(result).toContain('href="https://example.com/page"')
expect(result).toContain('target="_blank"')
})
it('should render external markdown file with heading anchor', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const env = createMockEnv()
const result = md.render('Check ![[https://example.com/doc.md#intro]] out.', env)
expect(result).toContain('<a')
// URLs with anchors are not properly handled in current implementation
expect(result).toContain('href="https://example.com/doc.md"')
})
})
// ==================== Block Embed with Inline-like Content ====================
describe('block embed with various content', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([])
})
it('should handle block embed of image with pipe settings', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[banner.jpg|800x200]]')
expect(result).toContain('<img')
expect(result).toContain('width: 800px')
expect(result).toContain('height: 200px')
})
it('should handle block embed of audio with controls', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[narration.mp3]]')
expect(result).toContain('<audio')
expect(result).toContain('controls="true"')
})
it('should handle block embed of video with all attributes', () => {
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
const result = md.render('![[presentation.mp4]]')
expect(result).toContain('<ArtPlayer')
expect(result).toContain(':fullscreen="true"')
expect(result).toContain(':flip="true"')
expect(result).toContain(':playback-rate="true"')
})
})
})

View File

@ -0,0 +1,297 @@
import { describe, expect, it } from 'vitest'
// Replicate the extractContentByHeadings logic for isolated testing
const HEADING_HASH_REG = /^#+/
const HEADING_ATTRS_REG = /(?:\{[^}]*\})?$/
interface ParsedHeading {
lineIndex: number
level: number
text: string
}
function extractContentByHeadings(content: string, headings: string[]): string {
if (!headings.length)
return content
const containers: Record<string, string> = {}
content = content.replaceAll(/(?<mark>:{3,})[\s\S]*?\k<mark>/g, (matched) => {
const key = `CONTAINER_${Object.keys(containers).length}`
containers[key] = matched
return `<!--container:${key}-->`
})
const lines = content.split(/\r?\n/)
const allHeadings: ParsedHeading[] = []
for (let i = 0; i < lines.length; i++) {
let text = lines[i].trimEnd()
let level = 0
text = text.replace(HEADING_HASH_REG, (matched) => {
level = matched.length
return ''
})
if (level) {
text = text.replace(HEADING_ATTRS_REG, '').trim()
allHeadings.push({ lineIndex: i, level, text })
}
}
let targetHeadingIndex = -1
let currentLevel = 0
let headingPointer = 0
for (let i = 0; i < allHeadings.length; i++) {
const heading = allHeadings[i]
if (headingPointer === 0) {
if (heading.text === headings[0]) {
headingPointer++
currentLevel = heading.level
if (headingPointer === headings.length) {
targetHeadingIndex = i
break
}
}
}
else {
if (heading.level > currentLevel && heading.text === headings[headingPointer]) {
headingPointer++
currentLevel = heading.level
if (headingPointer === headings.length) {
targetHeadingIndex = i
break
}
}
else if (heading.level <= currentLevel) {
if (heading.text === headings[0]) {
headingPointer = 1
currentLevel = heading.level
}
else {
headingPointer = 0
currentLevel = 0
}
}
}
}
if (targetHeadingIndex === -1) {
return ''
}
const targetHeading = allHeadings[targetHeadingIndex]
const startLine = targetHeading.lineIndex + 1
const targetLevel = targetHeading.level
let endLine = lines.length
for (let i = targetHeadingIndex + 1; i < allHeadings.length; i++) {
if (allHeadings[i].level <= targetLevel) {
endLine = allHeadings[i].lineIndex
break
}
}
const result = lines.slice(startLine, endLine).join('\n').trim()
return result.replaceAll(/<!--container:(.*?)-->/g, (_, key) => containers[key] ?? '')
}
describe('extractContentByHeadings', () => {
it('should return full content when no headings specified', () => {
const content = '# Title\n\nSome content here.'
expect(extractContentByHeadings(content, [])).toBe(content)
})
it('should extract content under single heading', () => {
const content = `# Title
Intro content.
## Section 1
Section 1 content.
## Section 2
Section 2 content.`
expect(extractContentByHeadings(content, ['Section 1'])).toBe('Section 1 content.')
})
it('should extract content under nested heading', () => {
const content = `# Title
## Level 2
### Level 3
Deep content.
## Back to Level 2
Other content.`
expect(extractContentByHeadings(content, ['Level 2', 'Level 3'])).toBe('Deep content.')
})
it('should stop at sibling heading of same level', () => {
const content = `# Title
## Section A
Content A.
## Section B
Content B.
### Nested in B
Nested content.`
expect(extractContentByHeadings(content, ['Section A'])).toBe('Content A.')
expect(extractContentByHeadings(content, ['Section B'])).toBe('Content B.\n\n### Nested in B\n\nNested content.')
})
it('should handle heading with attributes', () => {
const content = `# Title
## Section {#id .class data=value}
Section content with attributes.`
expect(extractContentByHeadings(content, ['Section'])).toBe('Section content with attributes.')
})
it('should preserve container syntax that appears within the extracted content', () => {
const content = `## Section
::: info
Container content
:::
Content after container.`
const result = extractContentByHeadings(content, ['Section'])
expect(result).toContain('::: info')
expect(result).toContain('Container content')
expect(result).toContain('Content after container')
})
it('should handle multiple containers within extracted content', () => {
const content = `## Section
::: info
First container
:::
::: warning
Second container
:::
Content.`
const result = extractContentByHeadings(content, ['Section'])
expect(result).toContain('::: info')
expect(result).toContain('First container')
expect(result).toContain('::: warning')
expect(result).toContain('Second container')
})
it('should return empty string when heading not found', () => {
const content = `# Title
## Section
Content.`
expect(extractContentByHeadings(content, ['Nonexistent'])).toBe('')
})
it('should handle deeply nested structure', () => {
const content = `# H1
## H2a
### H3a
H3a content.
### H3b
H3b content.
## H2b
H2b content.`
expect(extractContentByHeadings(content, ['H2a', 'H3b'])).toBe('H3b content.')
expect(extractContentByHeadings(content, ['H2a'])).toContain('H3a content')
expect(extractContentByHeadings(content, ['H2a'])).toContain('H3b content')
expect(extractContentByHeadings(content, ['H2a'])).not.toContain('H2b content')
})
it('should handle content with code blocks', () => {
const content = `# Title
## Section
\`\`\`js
const x = 1;
\`\`\`
More content.`
const result = extractContentByHeadings(content, ['Section'])
expect(result).toContain('```js')
expect(result).toContain('const x = 1;')
expect(result).toContain('More content.')
})
it('should handle content with blockquotes', () => {
const content = `# Title
## Section
> Blockquote text
Paragraph after.`
const result = extractContentByHeadings(content, ['Section'])
expect(result).toContain('> Blockquote text')
expect(result).toContain('Paragraph after.')
})
it('should handle headings at different levels with same text', () => {
const content = `# Title
## Summary
Summary content.
## Details
### Summary
Nested summary under details.
## Conclusion
Conclusion content.`
// Should match first "Summary" at level 2
expect(extractContentByHeadings(content, ['Summary'])).toBe('Summary content.')
})
it('should handle heading with trailing spaces', () => {
const content = `# Title
## Section
Section content.`
expect(extractContentByHeadings(content, ['Section'])).toBe('Section content.')
})
})

View File

@ -0,0 +1,156 @@
import type { App } from 'vuepress'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findFirstPage, initPagePaths, updatePagePaths } from '../src/node/obsidian/findFirstPage.js'
const mockGlobSync = vi.fn()
vi.mock('vuepress/utils', () => ({
tinyglobby: {
globSync: (...args: unknown[]) => mockGlobSync(...args),
},
path: {
dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/') || '.'),
extname: vi.fn((p: string) => {
const i = p.lastIndexOf('.')
return i > 0 ? p.slice(i) : ''
}),
join: vi.fn((...args: string[]) => args.join('/')),
},
}))
vi.mock('@vuepress/helper', () => ({
removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')),
}))
function createMockApp(pagePatterns = ['**/*.md']): App {
return {
pages: [],
options: {
pagePatterns,
},
dir: {
source: () => '/source',
},
} as unknown as App
}
describe('findFirstPage', () => {
beforeEach(() => {
mockGlobSync.mockReset()
})
describe('initPagePaths', () => {
it('should initialize page paths from glob pattern', () => {
mockGlobSync.mockReturnValue([
'README.md',
'guide.md',
'docs/api.md',
'docs/guide/intro.md',
])
const app = createMockApp()
initPagePaths(app)
expect(mockGlobSync).toHaveBeenCalledWith(['**/*.md'], {
cwd: '/source',
ignore: ['**/node_modules/**', '**/.vuepress/**'],
})
})
it('should sort page paths by directory depth', () => {
mockGlobSync.mockReturnValue([
'docs/a/b/c.md',
'a.md',
'docs/a.md',
])
const app = createMockApp()
initPagePaths(app)
// Should find a.md first because it's shortest
expect(findFirstPage('a', 'any/path.md')).toBe('a.md')
})
})
describe('updatePagePaths', () => {
it('should add new page path on create', () => {
mockGlobSync.mockReturnValue(['existing.md'])
const app = createMockApp()
initPagePaths(app)
updatePagePaths('new-page.md', 'create')
expect(findFirstPage('new-page', 'any/path.md')).toBe('new-page.md')
})
it('should remove page path on delete', () => {
mockGlobSync.mockReturnValue(['existing.md', 'to-delete.md'])
const app = createMockApp()
initPagePaths(app)
updatePagePaths('to-delete.md', 'delete')
expect(findFirstPage('to-delete', 'any/path.md')).toBeUndefined()
expect(findFirstPage('existing', 'any/path.md')).toBe('existing.md')
})
it('should not add empty filepath', () => {
mockGlobSync.mockReturnValue(['existing.md'])
const app = createMockApp()
initPagePaths(app)
const beforeUpdate = findFirstPage('existing', 'any/path.md')
updatePagePaths('', 'create')
expect(findFirstPage('existing', 'any/path.md')).toBe(beforeUpdate)
})
})
describe('findFirstPage matching logic', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([
'README.md',
'guide.md',
'docs/api.md',
'docs/guide/intro.md',
'docs/guide/advanced.md',
'page.md',
])
const app = createMockApp()
initPagePaths(app)
})
it('should return exact match', () => {
expect(findFirstPage('guide', 'any/path.md')).toBe('guide.md')
expect(findFirstPage('api', 'any/path.md')).toBe('docs/api.md')
})
it('should return path that ends with the filename', () => {
expect(findFirstPage('intro', 'any/path.md')).toBe('docs/guide/intro.md')
})
it('should add .md extension if no extension provided', () => {
expect(findFirstPage('page', 'any/path.md')).toBe('page.md')
})
it('should not add .md if extension already present', () => {
expect(findFirstPage('page.md', 'any/path.md')).toBe('page.md')
})
it('should find page via endsWith matching when given partial path', () => {
// When searching for 'guide/advanced', it should find 'docs/guide/advanced.md'
// because the pagePath ends with 'guide/advanced.md'
expect(findFirstPage('guide/advanced', 'any/path.md')).toBe('docs/guide/advanced.md')
})
it('should return undefined when page not found', () => {
expect(findFirstPage('nonexistent', 'any/path.md')).toBeUndefined()
expect(findFirstPage('does-not-exist', 'any/path.md')).toBeUndefined()
})
})
})

View File

@ -0,0 +1,85 @@
import type { App } from 'vuepress'
import MarkdownIt from 'markdown-it'
import { describe, expect, it, vi } from 'vitest'
import { obsidianPlugin } from '../src/node/obsidian/index.js'
vi.mock('vuepress/utils', async () => {
const actual = await vi.importActual('vuepress/utils')
return {
...actual,
tinyglobby: {
globSync: vi.fn(() => []),
},
}
})
function createMockApp(pages: App['pages'] = []): App {
return {
pages,
options: {
pagePatterns: ['**/*.md'],
},
dir: {
source: () => '/source',
},
} as unknown as App
}
function createMarkdownWithMockRules() {
return MarkdownIt({ html: true }).use((md) => {
md.block.ruler.before('code', 'import_code', () => false)
md.renderer.rules.import_code = () => ''
})
}
describe('obsidianPlugin', () => {
it('should enable all plugins by default', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages'])
obsidianPlugin(mockApp, md, {}, {})
// Wiki link should not work since findFirstPage returns undefined when pagePaths is empty
const wikiResult = md.render('[[Home]]')
expect(wikiResult).not.toContain('<VPLink')
const commentResult = md.render('%%comment%%')
expect(commentResult).not.toContain('comment')
})
it('should allow disabling specific plugins', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: { wikiLink: false } }, {})
const wikiResult = md.render('[[Page]]')
expect(wikiResult).not.toContain('<VPLink')
expect(wikiResult).toContain('[[Page]]')
})
it('should disable all plugins when obsidian is false', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: false }, {})
const result = md.render('![[image.png]]')
expect(result).toContain('![[image.png]]')
})
it('should disable embedLink when explicitly set to false', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: { embedLink: false } }, {})
const result = md.render('![[image.png]]')
expect(result).not.toContain('<img')
})
it('should disable comment when explicitly set to false', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: { comment: false } }, {})
const commentResult = md.render('%%comment%%')
expect(commentResult).toContain('%%comment%%')
})
})

View File

@ -0,0 +1,268 @@
import type { App } from 'vuepress'
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { initPagePaths } from '../src/node/obsidian/findFirstPage.js'
import { wikiLinkPlugin } from '../src/node/obsidian/wikiLink.js'
const mockGlobSync = vi.fn()
vi.mock('vuepress/utils', () => ({
tinyglobby: {
globSync: (...args: unknown[]) => mockGlobSync(...args),
},
path: {
dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/') || '.'),
extname: vi.fn((p: string) => {
const i = p.lastIndexOf('.')
return i > 0 ? p.slice(i) : ''
}),
join: vi.fn((...args: string[]) => args.join('/')),
},
}))
vi.mock('@vuepress/helper', () => ({
removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')),
}))
function createMockApp(pagePatterns = ['**/*.md']): App {
return {
pages: [],
options: {
pagePatterns,
},
dir: {
source: () => '/source',
},
} as unknown as App
}
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
return {
filePathRelative,
base: '/',
links: [],
}
}
describe('wikiLinkPlugin', () => {
beforeEach(() => {
mockGlobSync.mockReset()
})
// ==================== External Links ====================
describe('external links', () => {
it('should render external http link', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[https://example.com]]')
expect(result).toContain('<a')
expect(result).toContain('href="https://example.com"')
expect(result).toContain('target="_blank"')
})
it('should render external link with alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[https://example.com|Example Site]]')
expect(result).toContain('>Example Site<')
expect(result).toContain('href="https://example.com"')
})
it('should render external link with heading and alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[https://example.com/page#section|Go to Section]]')
expect(result).toContain('>Go to Section<')
expect(result).toContain('href="https://example.com/page#section"')
})
it('should render external link with heading but no alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[https://example.com/page#section]]')
expect(result).toContain('href="https://example.com/page#section"')
expect(result).toContain('https://example.com/page &gt; section</a>')
})
})
// ==================== Internal Hash Links ====================
describe('internal hash links', () => {
it('should render internal hash link for empty filename', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv('docs/page.md')
const result = md.render('[[#anchor]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="#anchor"')
})
it('should render internal hash link with alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv('docs/page.md')
const result = md.render('[[#anchor|Back to Top]]', env)
expect(result).toContain('>Back to Top<')
expect(result).toContain('href="#anchor"')
})
it('should render internal hash link with titles but no alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv('docs/page.md')
const result = md.render('[[#anchor1#anchor2]]', env)
expect(result).toContain('href="#anchor2"')
expect(result).toContain('&gt; anchor1 &gt; anchor2</template>')
})
})
// ==================== Internal Page Resolution ====================
describe('internal page resolution', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue([
'README.md',
'guide.md',
'docs/api.md',
'docs/guide/intro.md',
])
const app = createMockApp()
initPagePaths(app)
})
it('should render internal wiki link with VPLink', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[guide]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/guide.md"')
})
it('should render wiki link with heading anchor', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[guide#Getting Started]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/guide.md#getting-started"')
})
it('should render wiki link with alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[guide|Guide Page]]', env)
expect(result).toContain('Guide Page')
expect(result).toContain('<VPLink')
expect(result).toContain('href="/guide.md"')
})
it('should render wiki link with heading and alias', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[guide#Getting Started|Getting Started]]', env)
expect(result).toContain('Getting Started')
expect(result).toContain('href="/guide.md#getting-started"')
})
it('should track links in env', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
md.render('[[guide]]', env)
expect(env.links).toBeDefined()
expect(env.links!.length).toBeGreaterThan(0)
})
it('should find page by partial filename', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
// Should match docs/guide/intro.md when searching for "intro"
const result = md.render('[[intro]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/docs/guide/intro.md"')
})
})
// ==================== Page Not Found ====================
describe('when page does not exist', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue(['existing.md'])
const app = createMockApp()
initPagePaths(app)
})
it('should render as external anchor link', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[nonexistent]]')
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent"')
expect(result).toContain('target="_blank"')
})
it('should render with heading anchor for nonexistent page', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[nonexistent#section]]')
expect(result).toContain('href="/nonexistent#section"')
})
})
// ==================== Edge Cases ====================
describe('edge cases', () => {
beforeEach(() => {
mockGlobSync.mockReturnValue(['docs/page.md'])
const app = createMockApp()
initPagePaths(app)
})
it('should not parse wiki link without closing bracket', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[Page')
expect(result).toContain('[[Page')
})
it('should not parse empty wiki link', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const result = md.render('[[]]')
expect(result).toContain('[[]]')
})
it('should handle wiki link with extra whitespace', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[ page ]]', env)
expect(result).toContain('<VPLink')
})
it('should handle wiki link with multiple hashes', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
const result = md.render('[[page#h1#h2#h3]]', env)
expect(result).toContain('href="/docs/page.md#h3"')
})
it('should handle wiki link with pipe in filename', () => {
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin)
const env = createMockEnv()
// Filename with pipe character should be treated as alias separator
const result = md.render('[[page|alias]]', env)
expect(result).toContain('>alias<')
})
})
})

View File

@ -1,7 +1,7 @@
{ {
"name": "vuepress-plugin-md-power", "name": "vuepress-plugin-md-power",
"type": "module", "type": "module",
"version": "1.0.0-rc.192", "version": "1.0.0-rc.198",
"description": "The Plugin for VuePress 2 - markdown power", "description": "The Plugin for VuePress 2 - markdown power",
"author": "pengzhanbo <volodymyr@foxmail.com>", "author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT", "license": "MIT",
@ -36,8 +36,8 @@
"clean": "rimraf --glob ./lib", "clean": "rimraf --glob ./lib",
"copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib", "copy": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib",
"copy:watch": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib -w", "copy:watch": "cpx \"src/**/*.{d.ts,vue,css,scss,jpg,png}\" lib -w",
"tsdown": "tsdown", "tsdown": "tsdown --config-loader unrun",
"tsdown:watch": "tsdown --watch -- -c" "tsdown:watch": "tsdown --config-loader unrun --watch -- -c"
}, },
"peerDependencies": { "peerDependencies": {
"artplayer": "catalog:peer", "artplayer": "catalog:peer",
@ -97,6 +97,7 @@
"@vuepress/helper": "catalog:vuepress", "@vuepress/helper": "catalog:vuepress",
"@vueuse/core": "catalog:prod", "@vueuse/core": "catalog:prod",
"chokidar": "catalog:prod", "chokidar": "catalog:prod",
"gray-matter": "catalog:prod",
"image-size": "catalog:prod", "image-size": "catalog:prod",
"local-pkg": "catalog:prod", "local-pkg": "catalog:prod",
"lru-cache": "catalog:prod", "lru-cache": "catalog:prod",

View File

@ -1,19 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { QRCodeToDataURLOptions, QRCodeToStringOptions } from 'qrcode'
import type { QRCodeProps } from '../../shared/index.js' import type { QRCodeProps } from '../../shared/index.js'
import { isLinkWithProtocol } from '@vuepress/helper/client' import { isLinkWithProtocol } from '@vuepress/helper/client'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { resolveRoute, usePage, withBase } from 'vuepress/client' import { resolveRoute, usePage, withBase } from 'vuepress/client'
import { attemptLoadLogo, generateQRCode } from '../composables/qrcode.js'
const { title, text, mode, align = 'left', reverse = false, svg = false, width, level, version, mask, margin = 2, scale = 4, light, dark } = defineProps<QRCodeProps>() const { title, text, mode, align = 'left', reverse = false, width, level, version, mask, margin = 2, scale = 4, light, dark, logo, logoSize = '0.2' } = defineProps<QRCodeProps>()
const page = usePage() const page = usePage()
let qr: typeof import('qrcode') | null = null
const qrcode = ref('') const qrcode = ref('')
const parsedText = ref('') const parsedText = ref('')
const imgWidth = ref(300)
const isLink = ref(false) const isLink = ref(false)
const isInternalLink = ref(false)
const styles = computed(() => { const styles = computed(() => {
const size = typeof width === 'number' ? width : width ? Number.parseInt(width) : undefined const size = typeof width === 'number' ? width : width ? Number.parseInt(width) : undefined
@ -26,6 +26,7 @@ function parseText(): string | void {
return '' return ''
if (text === '.') { if (text === '.') {
isInternalLink.value = true
isLink.value = true isLink.value = true
return location.href.split(/[?#]/)[0] return location.href.split(/[?#]/)[0]
} }
@ -42,6 +43,7 @@ function parseText(): string | void {
if (notFound) { if (notFound) {
return text return text
} }
isInternalLink.value = true
isLink.value = true isLink.value = true
return new URL(`${withBase(path)}${rest.join('')}`, location.href).toString() return new URL(`${withBase(path)}${rest.join('')}`, location.href).toString()
} }
@ -50,10 +52,8 @@ function parseText(): string | void {
} }
onMounted(async () => { onMounted(async () => {
const callback = (_: any, url: string) => qrcode.value = url
watch( watch(
() => [text, svg, level, version, mask, margin, scale, light, dark], () => [text, level, version, mask, margin, scale, light, dark, logo, logoSize],
async () => { async () => {
const text = parseText() const text = parseText()
parsedText.value = text || '' parsedText.value = text || ''
@ -62,36 +62,39 @@ onMounted(async () => {
return return
} }
qr ??= (await import(/* webpackChunkName: "qrcode" */ 'qrcode')).default imgWidth.value = 300 * Math.round(window.devicePixelRatio || 1)
const opts: QRCodeToDataURLOptions & QRCodeToStringOptions = { qrcode.value = await generateQRCode(
{
text,
logo: await attemptLoadLogo(text, logo, isInternalLink.value),
logoSize,
},
{
version, version,
maskPattern: mask, maskPattern: mask,
errorCorrectionLevel: (level ? level.toUpperCase() : 'M') as any, width: imgWidth.value,
width: 300 * Math.round(window.devicePixelRatio || 1),
margin, margin,
scale, scale,
color: { dark, light }, color: { dark, light },
} },
)
if (svg)
qr.toString(text, { type: 'svg', ...opts }, callback)
else
qr.toDataURL(text, { type: 'image/png', ...opts }, callback)
}, },
{ immediate: true }, { immediate: true },
) )
}) })
onUnmounted(() => {
qr = null
})
</script> </script>
<template> <template>
<div v-if="qrcode" class="vp-qrcode" :class="{ card: mode === 'card', reverse, [align]: true }"> <div v-if="qrcode" class="vp-qrcode" :class="{ card: mode === 'card', reverse, [align]: true }">
<div class="qrcode-content"> <div class="qrcode-content">
<div v-if="svg" class="qrcode-svg" :style="styles" :title="parsedText" v-html="qrcode" /> <img
<img v-else class="qrcode-img" :src="qrcode" :alt="parsedText" :title="parsedText" :style="styles"> class="qrcode-img"
:src="qrcode"
:alt="parsedText"
:title="parsedText"
:style="styles"
:width="imgWidth" :height="imgWidth"
>
<div v-if="title && mode !== 'card'" class="qrcode-label"> <div v-if="title && mode !== 'card'" class="qrcode-label">
{{ title }} {{ title }}
</div> </div>
@ -166,7 +169,6 @@ onUnmounted(() => {
border-radius: 8px; border-radius: 8px;
} }
.vp-qrcode .qrcode-svg,
.vp-qrcode .qrcode-img { .vp-qrcode .qrcode-img {
width: var(--vp-qrcode-size); width: var(--vp-qrcode-size);
max-width: 100%; max-width: 100%;
@ -174,11 +176,6 @@ onUnmounted(() => {
aspect-ratio: 1/1; aspect-ratio: 1/1;
} }
.vp-qrcode .qrcode-svg :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.vp-qrcode .qrcode-info { .vp-qrcode .qrcode-info {
display: flex; display: flex;
flex: 1; flex: 1;

View File

@ -110,6 +110,7 @@ function onCopy(type: 'html' | 'md') {
} }
.vp-table .table-container table { .vp-table .table-container table {
display: table;
margin: 0; margin: 0;
} }

View File

@ -5,18 +5,63 @@ import { http } from '../utils/http.js'
import { sleep } from '../utils/sleep.js' import { sleep } from '../utils/sleep.js'
import { rustExecute } from './rustRepl.js' import { rustExecute } from './rustRepl.js'
/**
* CSS selectors for nodes to ignore when extracting code.
*
* CSS
*/
const ignoredNodes = ['.diff.remove', '.vp-copy-ignore'] const ignoredNodes = ['.diff.remove', '.vp-copy-ignore']
/**
* Regular expression for matching language class.
*
*
*/
const RE_LANGUAGE = /language-(\w+)/ const RE_LANGUAGE = /language-(\w+)/
/**
* API endpoints for code execution backends.
*
* API
*/
const api = { const api = {
go: 'https://api.pengzhanbo.cn/repl/golang/run', go: 'https://api.pengzhanbo.cn/repl/golang/run',
kotlin: 'https://api.pengzhanbo.cn/repl/kotlin/run', kotlin: 'https://api.pengzhanbo.cn/repl/kotlin/run',
} }
/**
* Pyodide instance for Python execution.
*
* Python Pyodide
*/
let pyodide: PyodideInterface | null = null let pyodide: PyodideInterface | null = null
/**
* Supported languages for code execution.
*
*
*/
type Lang = 'kotlin' | 'go' | 'rust' | 'python' type Lang = 'kotlin' | 'go' | 'rust' | 'python'
/**
* Function type for code execution.
*
*
*/
type ExecuteFn = (code: string) => Promise<any> type ExecuteFn = (code: string) => Promise<any>
/**
* Map of language to execution function.
*
*
*/
type ExecuteMap = Record<Lang, ExecuteFn> type ExecuteMap = Record<Lang, ExecuteFn>
/**
* Language alias mapping.
*
*
*/
const langAlias: Record<string, string> = { const langAlias: Record<string, string> = {
kt: 'kotlin', kt: 'kotlin',
kotlin: 'kotlin', kotlin: 'kotlin',
@ -27,12 +72,33 @@ const langAlias: Record<string, string> = {
python: 'python', python: 'python',
} }
/**
* List of supported languages.
*
*
*/
const supportLang: Lang[] = ['kotlin', 'go', 'rust', 'python'] const supportLang: Lang[] = ['kotlin', 'go', 'rust', 'python']
/**
* Resolve language name from alias.
*
*
*
* @param lang - Language or alias /
* @returns Resolved language name /
*/
function resolveLang(lang?: string) { function resolveLang(lang?: string) {
return lang ? langAlias[lang] || lang : '' return lang ? langAlias[lang] || lang : ''
} }
/**
* Resolve code content from HTML element, ignoring specified nodes.
*
* HTML
*
* @param el - HTML element / HTML
* @returns Code content /
*/
export function resolveCode(el: HTMLElement): string { export function resolveCode(el: HTMLElement): string {
const clone = el.cloneNode(true) as HTMLElement const clone = el.cloneNode(true) as HTMLElement
clone clone
@ -42,6 +108,14 @@ export function resolveCode(el: HTMLElement): string {
return clone.textContent || '' return clone.textContent || ''
} }
/**
* Resolve code information from HTML element.
*
* HTML
*
* @param el - HTML element / HTML
* @returns Object with language and code /
*/
export function resolveCodeInfo(el: HTMLDivElement): { export function resolveCodeInfo(el: HTMLDivElement): {
lang: Lang lang: Lang
code: string code: string
@ -57,19 +131,55 @@ export function resolveCodeInfo(el: HTMLDivElement): {
return { lang: resolveLang(lang) as Lang, code } return { lang: resolveLang(lang) as Lang, code }
} }
/**
* Result interface for useCodeRepl composable.
*
* useCodeRepl
*/
interface UseCodeReplResult { interface UseCodeReplResult {
/** Current language / 当前语言 */
lang: Ref<Lang | undefined> lang: Ref<Lang | undefined>
/** Whether the code is loaded / 代码是否已加载 */
loaded: Ref<boolean> loaded: Ref<boolean>
/** Whether this is the first run / 是否为首次运行 */
firstRun: Ref<boolean> firstRun: Ref<boolean>
/** Whether execution is finished / 执行是否完成 */
finished: Ref<boolean> finished: Ref<boolean>
/** Standard output lines / 标准输出行 */
stdout: Ref<string[]> stdout: Ref<string[]>
/** Standard error lines / 标准错误行 */
stderr: Ref<string[]> stderr: Ref<string[]>
/** Error message / 错误信息 */
error: Ref<string> error: Ref<string>
/** Backend version / 后端版本 */
backendVersion: Ref<string> backendVersion: Ref<string>
/** Clean run state / 清理运行状态 */
onCleanRun: () => void onCleanRun: () => void
/** Run code execution / 运行代码执行 */
onRunCode: () => Promise<void> onRunCode: () => Promise<void>
} }
/**
* Composable for code REPL functionality.
*
* REPL
*
* This composable provides functionality to execute code in various languages
* (Kotlin, Go, Rust, Python) and manage the execution state.
*
* KotlinGoRustPython
*
* @param el - Reference to the code element /
* @returns REPL state and methods / REPL
*
* @example
* ```vue
* <script setup>
* const codeEl = ref(null)
* const { onRunCode, stdout, stderr, loaded } = useCodeRepl(codeEl)
* </script>
* ```
*/
export function useCodeRepl(el: Ref<HTMLDivElement | null>): UseCodeReplResult { export function useCodeRepl(el: Ref<HTMLDivElement | null>): UseCodeReplResult {
const lang = ref<Lang>() const lang = ref<Lang>()
const loaded = ref(true) const loaded = ref(true)
@ -227,36 +337,74 @@ export function useCodeRepl(el: Ref<HTMLDivElement | null>): UseCodeReplResult {
} }
} }
/**
* Request interface for Golang execution API.
*
* Golang API
*/
interface GolangRequest { interface GolangRequest {
/** Code to execute / 要执行的代码 */
code: string code: string
/** Go version / Go 版本 */
version?: '' | 'goprev' | 'gotip' version?: '' | 'goprev' | 'gotip'
} }
/**
* Response interface for Golang execution API.
*
* Golang API
*/
interface GolangResponse { interface GolangResponse {
/** Execution events / 执行事件 */
events?: { events?: {
/** Event message / 事件消息 */
message: '' message: ''
/** Event kind / 事件类型 */
kind: 'stdout' | 'stderr' kind: 'stdout' | 'stderr'
/** Event delay / 事件延迟 */
delay: number delay: number
}[] }[]
/** Error message / 错误信息 */
error?: string error?: string
/** Go version / Go 版本 */
version: string version: string
} }
/**
* Request interface for Kotlin execution API.
*
* Kotlin API
*/
interface KotlinRequest { interface KotlinRequest {
/** Command line arguments / 命令行参数 */
args?: string args?: string
/** Files to compile / 要编译的文件 */
files: { files: {
/** File name / 文件名 */
name: string name: string
/** Public ID / 公共 ID */
publicId: string publicId: string
/** File content / 文件内容 */
text: string text: string
}[] }[]
} }
/**
* Response interface for Kotlin execution API.
*
* Kotlin API
*/
interface KotlinResponse { interface KotlinResponse {
/** Execution output / 执行输出 */
text: string text: string
/** Kotlin version / Kotlin 版本 */
version: string version: string
/** Compilation errors / 编译错误 */
errors: { errors: {
[filename: string]: { [filename: string]: {
/** Error message / 错误信息 */
message: string message: string
/** Error severity / 错误严重程度 */
severity: 'ERROR' | 'WARNING' severity: 'ERROR' | 'WARNING'
}[] }[]
} }

View File

@ -1,11 +1,48 @@
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
/**
* Composable for decrypting encrypted content.
*
*
*
* This composable provides a decrypt function that uses the Web Crypto API
* to decrypt content encrypted with AES-CBC algorithm.
*
* 使 Web Crypto API 使 AES-CBC
*
* @param config - Configuration containing salt and IV / IV
* @returns Object with decrypt function /
*
* @example
* ```ts
* const config = computed(() => ({ salt: [...], iv: [...] }))
* const { decrypt } = useDecrypt(config)
* const content = await decrypt('password', 'encrypted-content')
* ```
*/
export function useDecrypt( export function useDecrypt(
config: ComputedRef<{ salt: number[], iv: number[] }>, config: ComputedRef<{ salt: number[], iv: number[] }>,
) { ) {
/**
* Convert number array to Uint8Array.
*
* Uint8Array
*
* @param raw - Number array /
* @returns Uint8Array / Uint8Array
*/
const toUnit8Array = (raw: number[]) => Uint8Array.from(raw) const toUnit8Array = (raw: number[]) => Uint8Array.from(raw)
return { return {
/**
* Decrypt encrypted content using password.
*
* 使
*
* @param password - Decryption password /
* @param text - Encrypted content /
* @returns Decrypted content or undefined / undefined
*/
decrypt: async (password: string, text: string) => { decrypt: async (password: string, text: string) => {
if (!password) if (!password)
return return
@ -29,6 +66,14 @@ export function useDecrypt(
} }
} }
/**
* Get key material from password using PBKDF2.
*
* 使 PBKDF2
*
* @param password - Password string /
* @returns CryptoKey for key derivation / CryptoKey
*/
function getKeyMaterial(password: string) { function getKeyMaterial(password: string) {
const enc = new TextEncoder() const enc = new TextEncoder()
return window.crypto.subtle.importKey( return window.crypto.subtle.importKey(
@ -41,7 +86,13 @@ function getKeyMaterial(password: string) {
} }
/** /**
* crypto * Derive encryption key from key material using PBKDF2.
*
* 使 PBKDF2
*
* @param keyMaterial - Key material from password /
* @param salt - Salt for key derivation /
* @returns Derived CryptoKey for AES-CBC / AES-CBC CryptoKey
*/ */
function getCryptoDeriveKey(keyMaterial: CryptoKey, salt: BufferSource) { function getCryptoDeriveKey(keyMaterial: CryptoKey, salt: BufferSource) {
return window.crypto.subtle.deriveKey( return window.crypto.subtle.deriveKey(

View File

@ -1,12 +1,61 @@
import { onContentUpdated } from 'vuepress/client' import { onContentUpdated } from 'vuepress/client'
/**
* Attribute name for mark mode.
*
*
*/
const MARK_MODE_ATTR = 'data-mark-mode' const MARK_MODE_ATTR = 'data-mark-mode'
/**
* Lazy mode constant.
*
*
*/
const MARK_MODE_LAZY = 'lazy' const MARK_MODE_LAZY = 'lazy'
/**
* CSS class for visible marks.
*
* CSS
*/
const MARK_VISIBLE_CLASS = 'vp-mark-visible' const MARK_VISIBLE_CLASS = 'vp-mark-visible'
/**
* Attribute name for mark boundary.
*
*
*/
const MARK_BOUND_ATTR = 'data-vp-mark-bound' const MARK_BOUND_ATTR = 'data-vp-mark-bound'
/**
* CSS selector for mark elements.
*
* CSS
*/
const MARK_SELECTOR = 'mark' const MARK_SELECTOR = 'mark'
/**
* CSS selector for bounded mark elements.
*
* CSS
*/
const BOUND_SELECTOR = `${MARK_SELECTOR}[${MARK_BOUND_ATTR}="1"]` const BOUND_SELECTOR = `${MARK_SELECTOR}[${MARK_BOUND_ATTR}="1"]`
/**
* Setup mark highlight animation for lazy mode.
*
*
*
* When mode is 'lazy', marks will animate into view using IntersectionObserver.
* When mode is 'eager', marks are immediately visible without animation.
*
* 'lazy' 使 IntersectionObserver
* 'eager'
*
* @param mode - Animation mode: 'lazy' or 'eager' / 'lazy' 'eager'
*
* @example
* ```ts
* // In client config setup
* setupMarkHighlight('lazy')
* ```
*/
export function setupMarkHighlight(mode: 'lazy' | 'eager'): void { export function setupMarkHighlight(mode: 'lazy' | 'eager'): void {
if (typeof window === 'undefined' || __VUEPRESS_SSR__) if (typeof window === 'undefined' || __VUEPRESS_SSR__)
return return

View File

@ -17,6 +17,14 @@ import { withBase } from 'vuepress/client'
import { ensureEndingSlash, isLinkHttp } from 'vuepress/shared' import { ensureEndingSlash, isLinkHttp } from 'vuepress/shared'
import { pluginOptions } from '../options.js' import { pluginOptions } from '../options.js'
/**
* Build query string from PDF options.
*
* PDF
*
* @param options - PDF token metadata / PDF
* @returns Query string /
*/
function queryStringify(options: PDFTokenMeta): string { function queryStringify(options: PDFTokenMeta): string {
const { page, noToolbar, zoom } = options const { page, noToolbar, zoom } = options
const params = [ const params = [
@ -32,6 +40,16 @@ function queryStringify(options: PDFTokenMeta): string {
return queryString return queryString
} }
/**
* Render PDF viewer in the specified element.
*
* PDF
*
* @param el - Container element /
* @param url - PDF URL / PDF URL
* @param embedType - Embed type: 'pdfjs', 'iframe', or 'embed' /
* @param options - PDF token metadata / PDF
*/
export function renderPDF( export function renderPDF(
el: HTMLElement, el: HTMLElement,
url: string, url: string,
@ -72,6 +90,20 @@ export function renderPDF(
el.appendChild(pdf) el.appendChild(pdf)
} }
/**
* Composable for PDF viewer functionality.
*
* PDF
*
* This function detects browser capabilities and chooses the appropriate
* embedding method for PDF display (PDF.js, iframe, or embed).
*
* PDFPDF.jsiframe embed
*
* @param el - Container element /
* @param url - PDF URL / PDF URL
* @param options - PDF token metadata / PDF
*/
export function usePDF( export function usePDF(
el: HTMLElement, el: HTMLElement,
url: string, url: string,
@ -86,10 +118,10 @@ export function usePDF(
const isModernBrowser = typeof window.Promise === 'function' const isModernBrowser = typeof window.Promise === 'function'
// Quick test for mobile devices. // Quick test for mobile devices.
const isMobileDevice = isiPad(userAgent) || isMobile(userAgent) const isMobileDevice = isiPad() || isMobile()
// Safari desktop requires special handling // Safari desktop requires special handling
const isSafariDesktop = !isMobileDevice && isSafari(userAgent) const isSafariDesktop = !isMobileDevice && isSafari()
const isFirefoxWithPDFJS const isFirefoxWithPDFJS
= !isMobileDevice = !isMobileDevice

View File

@ -0,0 +1,83 @@
import type { Prettify } from '@pengzhanbo/utils'
import type { QRCodeByteSegment, QRCodeErrorCorrectionLevel, QRCodeRenderersOptions, QRCodeToDataURLOptions, QRCodeToStringOptions } from 'qrcode'
export interface GenerateQRCodeConfig {
text: string
logo?: string
logoSize?: string
}
interface QRCodeInstance {
toCanvas: (str: string | QRCodeByteSegment[], options: QRCodeRenderersOptions) => Promise<HTMLCanvasElement>
toPNG: (str: string | QRCodeByteSegment[], options: QRCodeToDataURLOptions) => Promise<string>
}
let qr: QRCodeInstance | null = null
async function initQRCodeInstance() {
if (qr)
return qr
const qrcode = (await import(/* webpackChunkName: "qrcode" */ 'qrcode')).default
qr = {
toCanvas: (text: string | QRCodeByteSegment[], options: QRCodeRenderersOptions) => {
return new Promise((resolve, reject) =>
qrcode.toCanvas(text, options, (error, canvas) => error ? reject(error) : resolve(canvas)),
)
},
toPNG: (text, options) => qrcode.toDataURL(text, { type: 'image/png', ...options }),
}
return qr
}
export async function generateQRCode(
{ text, logo, logoSize = '0.2' }: GenerateQRCodeConfig,
options: Prettify<QRCodeToDataURLOptions & QRCodeToStringOptions & QRCodeRenderersOptions>,
): Promise<string> {
const { toCanvas, toPNG } = await initQRCodeInstance()
const segments: QRCodeByteSegment[] = [{ data: new TextEncoder().encode(text), mode: 'byte' }]
const qrWidth = options.width!
if (logo) {
// 有 logo 时,需要设置 errorCorrectionLevel 为 H
// 因为 logo 会占用二维码的一部分空间,导致二维码的纠错能力下降
// 所以需要增加纠错能力
const level = options.errorCorrectionLevel ?? 'H'
options.errorCorrectionLevel = (level.length === 1 ? level.toUpperCase() : `${level[0].toUpperCase()}${level[1].toLowerCase()}`) as unknown as QRCodeErrorCorrectionLevel
const logoImg = await loadImage(logo)
const actualWith = Number.parseFloat(logoSize) * qrWidth
const actualHeight = actualWith / logoImg.width * logoImg.height
const dx = (qrWidth - actualWith) / 2
const dy = (qrWidth - actualHeight) / 2
const canvas = await toCanvas(segments, options)
const ctx = canvas.getContext('2d')!
// 绘制 logo 背景
ctx.fillStyle = options.color?.light || '#fff'
ctx.roundRect(dx, dy, actualWith, actualHeight, actualWith / 20)
ctx.fill()
// 绘制 logo 图片
ctx.drawImage(logoImg, dx, dy, actualWith, actualHeight)
return canvas.toDataURL()
}
const level = options.errorCorrectionLevel ?? 'M'
options.errorCorrectionLevel = (level.length === 1 ? level.toUpperCase() : `${level[0].toUpperCase()}${level[1].toLowerCase()}`) as unknown as QRCodeErrorCorrectionLevel
return await toPNG(segments, options)
}
export async function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.src = url
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('Failed to load image'))
})
}
export async function attemptLoadLogo(text: string, logo: string | undefined, isInternalLink: boolean): Promise<string> {
if (logo)
return logo
if (isInternalLink)
return (document.querySelector('link[rel="icon"]') as HTMLLinkElement)?.href
return ''
}

View File

@ -8,8 +8,18 @@ declare const __MD_POWER_HLSJS_INSTALLED__: boolean
declare const __MD_POWER_MPEGTSJS_INSTALLED__: boolean declare const __MD_POWER_MPEGTSJS_INSTALLED__: boolean
declare const __MD_POWER_ENCRYPT_LOCALES__: LocaleConfig<EncryptSnippetLocale> declare const __MD_POWER_ENCRYPT_LOCALES__: LocaleConfig<EncryptSnippetLocale>
/**
* Plugin options injected at build time.
*
*
*/
export const pluginOptions: MarkdownPowerPluginOptions = __MD_POWER_INJECT_OPTIONS__ export const pluginOptions: MarkdownPowerPluginOptions = __MD_POWER_INJECT_OPTIONS__
/**
* Package installation status for video streaming libraries.
*
*
*/
export const installed: { export const installed: {
dashjs: boolean dashjs: boolean
hlsjs: boolean hlsjs: boolean
@ -20,6 +30,11 @@ export const installed: {
mpegtsjs: __MD_POWER_MPEGTSJS_INSTALLED__, mpegtsjs: __MD_POWER_MPEGTSJS_INSTALLED__,
} }
/**
* Supported video types for ArtPlayer.
*
* ArtPlayer
*/
export const ART_PLAYER_SUPPORTED_VIDEO_TYPES: string[] = ['mp4', 'mp3', 'webm', 'ogg'] export const ART_PLAYER_SUPPORTED_VIDEO_TYPES: string[] = ['mp4', 'mp3', 'webm', 'ogg']
if (installed.dashjs) { if (installed.dashjs) {
@ -34,12 +49,27 @@ if (installed.mpegtsjs) {
ART_PLAYER_SUPPORTED_VIDEO_TYPES.push('ts', 'flv') ART_PLAYER_SUPPORTED_VIDEO_TYPES.push('ts', 'flv')
} }
/**
* Injection key for timeline component communication.
*
* 线
*/
export const INJECT_TIMELINE_KEY: symbol = Symbol( export const INJECT_TIMELINE_KEY: symbol = Symbol(
__VUEPRESS_DEV__ ? 'timeline' : '', __VUEPRESS_DEV__ ? 'timeline' : '',
) )
/**
* Injection key for collapse component communication.
*
*
*/
export const INJECT_COLLAPSE_KEY: symbol = Symbol( export const INJECT_COLLAPSE_KEY: symbol = Symbol(
__VUEPRESS_DEV__ ? 'collapse' : '', __VUEPRESS_DEV__ ? 'collapse' : '',
) )
/**
* Encrypt snippet locale data.
*
*
*/
export const ENCRYPT_LOCALES = __MD_POWER_ENCRYPT_LOCALES__ export const ENCRYPT_LOCALES = __MD_POWER_ENCRYPT_LOCALES__

Some files were not shown because too many files have changed in this diff Show More