Compare commits

..

29 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
79 changed files with 7526 additions and 2281 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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,
demo: true,
encrypt: true,
obsidian: true,
npmTo: ['pnpm', 'yarn', 'npm'],
repl: {
go: true,

View File

@ -17,7 +17,7 @@ docs:
logo: /plume.png
url: https://theme-plume.vuejs.press
repo: https://github.com/pengzhanbo/vuepress-theme-plume
preview: /images/demos/plume.jpg
preview: /images/demos/plume.webp
-
name: city walk 城市漫步
desc: 致力于汇聚全国350多个城市的户外活动地点与文化场馆的开放数据平台。
@ -25,12 +25,6 @@ docs:
url: https://shenzhen.citywalk.group/
repo: https://github.com/sunshang-hl/CityWalk
preview: https://pub-187e90a3327b41ccb8869558b6b8bbc0.r2.dev/city-shenzhen/2024/12/ed251c4438f722dffd6cb95db86c0d56.jpg
-
name: 哦麦 MC
desc: 我的世界教学文档。
logo: https://s.xc.life/img/img/minecraft-154749_1280.png
url: https://ohmymc.com/
preview: https://s.xc.life/img/img/20241228225159139.png
-
name: NcatBotDocs
desc: NcatBot一个 QQ 机器人框架项目的使用文档。
@ -96,8 +90,8 @@ docs:
-
name: mcenahle Docs
desc: mcenahle 的文档网站。
logo: https://d.mcenahle.cn/images/logo.png
url: https://d.mcenahle.cn/
logo: https://d.mcenahle.com/images/logo.png
url: https://d.mcenahle.com/
preview: https://mcenahle.cn/resources/docs-site-preview.jpg
blog:
@ -192,13 +186,6 @@ blog:
url: https://ar0m.com
repo: https://github.com/jindongjie/blog-vuepress-2025
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: 菲兹克斯喵
desc: 一名物理系学生的笔记和生活
@ -294,6 +281,12 @@ blog:
[前往 **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" />

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
---
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
## Overview
@ -42,7 +42,7 @@ Inline syntax is suitable for shorter `text`, such as links.
<!-- Basic Syntax -->
@[qrcode](text)
<!-- With Attributes -->
@[qrcode card svg title="xxx" align="center"](text)
@[qrcode card title="xxx" align="center" logo="/plume.png"](text)
```
### 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.
```md
::: qrcode card svg title="xxx" align="center"
::: qrcode card title="xxx" align="center"
text
:::
```
@ -64,8 +64,13 @@ text
::: field name="card" type="boolean" optional default="false"
Whether to enable the card style.
:::
::: field name="svg" type="boolean" optional default="false"
Whether to render the QR code in SVG format. The default format is PNG.
::: field name="logo" type="string" optional
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
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.
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
**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)
### 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
::: tip Internal page links will automatically have a logo added
:::
**Input:**
```md

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: 新
---
@[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 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` 文本较长时,比如 段落,多行文本 等。
```md
::: qrcode card svg title="xxx" align="center"
::: qrcode card title="xxx" align="center"
text
:::
```
@ -64,8 +64,13 @@ text
::: field name="card" type="boolean" optional default="false"
是否启用卡片样式。
:::
::: field name="svg" type="boolean" optional default="false"
是否将二维码渲染为 SVG 格式。默认渲染为 PNG 格式。
::: field name="logo" type="string" optional
二维码 logo 图片路径。显示于二维码中心。
仅支持 绝对路径。
:::
::: field name="logoSize" type="number" optional default="0.2"
logo 相对于二维码 大小比例
:::
::: field name="title" type="string" optional
二维码标题。
@ -98,6 +103,8 @@ text
更高级别提供更好的抗错能力,但会降低符号的容量。
如果二维码符号可能被损坏的几率较低,则可以安全使用低纠错级别,如低或中。
当二维码中包含 logo 时,默认值为 `H`
:::
::: field name="version" type="number" optional
**二维码版本**
@ -127,8 +134,23 @@ text
@[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

View File

@ -99,6 +99,7 @@ search: false
| *纪 | 2026-01-03 | 9.90 | 新年快乐(,,>‿<,,),感谢佬 |
| J*n | 2026-01-22 | 10.00 | 用本开源主题搭了好几个网站了,作者耐心解答,添加合理功能需求,必须支持一下,辛苦了❤️ |
| *燧 | 2026-03-14 | 8.88 | 智齿主播,大佬加油 <br>(作者回复:啊?主播?我不是啊) |
| *飞 | 2026-04-25 | 9.90 | - |
</div>

View File

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

View File

@ -10,6 +10,8 @@ export default config({
'skills',
'docs/snippet/code-block.snippet.md',
'docs/snippet/whitespace.snippet.md',
'docs/en/guide/markdown/obsidian.md',
'docs/guide/markdown/obsidian.md',
],
globals: {
__VUEPRESS_VERSION__: 'readonly',

View File

@ -1,9 +1,9 @@
{
"name": "vuepress-theme-plume-monorepo",
"type": "module",
"version": "1.0.0-rc.193",
"version": "1.0.0-rc.198",
"private": true,
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@10.33.2",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",
"keywords": [
@ -33,7 +33,7 @@
"lint:css": "stylelint **/*.{css,vue}",
"test": "cross-env TZ=Etc/UTC vitest --coverage",
"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:sync": "node scripts/mirror-sync.mjs",
"release:publish": "pnpm -r publish --tag latest",
@ -57,7 +57,8 @@
"@vitest/coverage-v8": "catalog:dev",
"bumpp": "catalog:dev",
"commitizen": "catalog:dev",
"conventional-changelog-cli": "catalog:dev",
"conventional-changelog": "catalog:dev",
"conventional-changelog-angular": "catalog:dev",
"cpx2": "catalog:dev",
"cross-env": "catalog:dev",
"cz-conventional-changelog": "catalog:dev",
@ -85,17 +86,23 @@
}
},
"resolutions": {
"@bufbuild/protobuf": "^2.11.0",
"@bufbuild/protobuf": "^2.12.0",
"@eslint-community/eslint-utils": "catalog:peer",
"@shikijs/core": "^4.0.2",
"@shikijs/twoslash": "^4.0.2",
"@typescript-eslint/types": "catalog:peer",
"@typescript-eslint/utils": "catalog:peer",
"baseline-browser-mapping": "^2.10.13",
"@xmldom/xmldom": ">=0.9.10",
"baseline-browser-mapping": "^2.10.22",
"chokidar": "catalog:prod",
"dompurify": ">=3.4.1",
"esbuild": "catalog:prod",
"follow-redirects": ">=1.16.0",
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1",
"sass-embedded": "catalog:peer",
"shiki": "^4.0.2",
"tmp": ">=0.2.5",
"vite": "catalog:dev",
"vue-router": "catalog:prod"
},

View File

@ -1,7 +1,7 @@
{
"name": "@vuepress-plume/plugin-fonts",
"type": "module",
"version": "1.0.0-rc.193",
"version": "1.0.0-rc.198",
"description": "The Plugin for VuePress 2 - fonts",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

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",
"type": "module",
"version": "1.0.0-rc.193",
"version": "1.0.0-rc.198",
"description": "The Plugin for VuePress 2 - markdown power",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",
@ -97,6 +97,7 @@
"@vuepress/helper": "catalog:vuepress",
"@vueuse/core": "catalog:prod",
"chokidar": "catalog:prod",
"gray-matter": "catalog:prod",
"image-size": "catalog:prod",
"local-pkg": "catalog:prod",
"lru-cache": "catalog:prod",

View File

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

View File

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

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

@ -88,6 +88,7 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
let isTable = false
let colIndex = 0
let rowIndex = 0
let skipCells = 0
for (const token of tableTokens) {
if (token.type === 'table_open')
isTable = true
@ -100,13 +101,39 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
if (token.type === 'tr_open') {
rowIndex++
colIndex = 0
// 当 th 设置了 colspan 时,需要跳过空单元格
skipCells = 0
}
// cell (rowIndex, colIndex)
if (token.type === 'th_open' || token.type === 'td_open') {
if (skipCells > 0) {
// Skip this empty cell`th` element only
if (token.type === 'th_open')
token.hidden = true
skipCells--
continue
}
colIndex++
const classes = cells[rowIndex]?.[colIndex] || rows[rowIndex] || cols[colIndex]
if (classes)
token.attrJoin('class', classes)
// Check for colspan attribute
const colspanIndex = token.attrIndex('colspan')
if (colspanIndex >= 0) {
const colspanValue = Number.parseInt(token.attrs![colspanIndex][1])
if (!Number.isNaN(colspanValue) && colspanValue > 1) {
skipCells = colspanValue - 1
}
}
}
// Skip the content and close tokens for skipped cells
if (skipCells > 0 && (token.type === 'text' || token.type === 'th_close' || token.type === 'td_close')) {
if (token.type === 'th_close')
// Skip this empty cell`th` element only
token.hidden = true
continue
}
}

View File

@ -1,6 +1,6 @@
import type { DemoFile, MarkdownDemoEnv } from '../../../shared/demo.js'
const SCRIPT_RE = /<script.*?>/
const SCRIPT_RE = /<script\b[^>]*>/
export function insertSetupScript({ export: name, path }: DemoFile, env: MarkdownDemoEnv): void {
const imports = `import ${name ? `${name} from ` : ''}'${path}';`

View File

@ -18,7 +18,7 @@ const installed = {
mpegtsjs: isPackageExists('mpegts.js'),
}
const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv']
export const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv', 'mkv', 'mov', 'ogv']
export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
createEmbedRuleBlock<ArtPlayerTokenMeta>(md, {
@ -51,7 +51,7 @@ export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
})
}
function checkSupportType(type?: string) {
export function checkSupportType(type?: string) {
if (!type)
return

View File

@ -13,4 +13,34 @@ export const deLocale: MDPowerLocaleData = {
warningTitle: '🚨 Sicherheitswarnung:',
warningText: 'Ihre Verbindung ist nicht mit HTTPS verschlüsselt, was ein Risiko für Inhaltslecks darstellt und den Zugriff auf verschlüsselte Inhalte verhindert.',
},
obsidian: {
note: 'Hinweis',
quote: 'Zitat',
cite: 'Quellenangabe',
tip: 'Tipp',
hint: 'Hinweis',
info: 'Info',
todo: 'Aufgabe',
success: 'Erfolg',
check: 'Prüfung',
done: 'Erledigt',
warning: 'Warnung',
question: 'Frage',
help: 'Hilfe',
faq: 'FAQ',
caution: 'Vorsicht',
attention: 'Achtung',
failure: 'Fehlschlag',
fail: 'Gescheitert',
missing: 'Fehlend',
danger: 'Gefahr',
error: 'Fehler',
bug: 'Bug',
important: 'Wichtig',
example: 'Beispiel',
details: 'Details',
abstract: 'Zusammenfassung',
summary: 'Zusammenfassung',
tldr: 'TL;DR',
},
}

View File

@ -13,4 +13,34 @@ export const enLocale: MDPowerLocaleData = {
warningTitle: '🚨 Security Warning:',
warningText: 'Your connection is not encrypted with HTTPS, posing a risk of content leakage and preventing access to encrypted content.',
},
obsidian: {
note: 'Note',
quote: 'Quote',
cite: 'Cite',
tip: 'Tip',
hint: 'Hint',
info: 'Info',
todo: 'Todo',
success: 'Success',
check: 'Check',
done: 'Done',
warning: 'Warning',
question: 'Question',
help: 'Help',
faq: 'FAQ',
caution: 'Caution',
attention: 'Attention',
failure: 'Failure',
fail: 'Fail',
missing: 'Missing',
danger: 'Danger',
error: 'Error',
bug: 'Bug',
important: 'Important',
example: 'Example',
details: 'Details',
abstract: 'Abstract',
summary: 'Summary',
tldr: 'TL;DR',
},
}

View File

@ -13,4 +13,34 @@ export const frLocale: MDPowerLocaleData = {
warningTitle: '🚨 Avertissement de sécurité :',
warningText: 'Votre connexion n\'est pas chiffrée avec HTTPS, ce qui présente un risque de fuite de contenu et empêche l\'accès au contenu chiffré.',
},
obsidian: {
note: 'Note',
quote: 'Citation',
cite: 'Référence',
tip: 'Astuce',
hint: 'Indice',
info: 'Info',
todo: 'À faire',
success: 'Succès',
check: 'Vérification',
done: 'Terminé',
warning: 'Avertissement',
question: 'Question',
help: 'Aide',
faq: 'FAQ',
caution: 'Prudence',
attention: 'Attention',
failure: 'Échec',
fail: 'Échoué',
missing: 'Manquant',
danger: 'Danger',
error: 'Erreur',
bug: 'Bug',
important: 'Important',
example: 'Exemple',
details: 'Détails',
abstract: 'Résumé',
summary: 'Sommaire',
tldr: 'En bref',
},
}

View File

@ -13,4 +13,34 @@ export const jaLocale: MDPowerLocaleData = {
warningTitle: '🚨 セキュリティ警告:',
warningText: '接続がHTTPSで暗号化されていないため、コンテンツの漏洩リスクがあり、暗号化されたコンテンツへのアクセスができません。',
},
obsidian: {
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: '短縮版',
},
}

View File

@ -13,4 +13,34 @@ export const koLocale: MDPowerLocaleData = {
warningTitle: '🚨 보안 경고:',
warningText: '연결이 HTTPS로 암호화되지 않아 내용 유출 위험이 있으며, 암호화된 콘텐츠에 접근할 수 없습니다.',
},
obsidian: {
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: '요약',
},
}

View File

@ -13,4 +13,34 @@ export const ruLocale: MDPowerLocaleData = {
warningTitle: '🚨 Предупреждение безопасности:',
warningText: 'Ваше соединение не защищено HTTPS, что создает риск утечки данных и блокирует доступ к зашифрованному контенту.',
},
obsidian: {
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: 'Кратко',
},
}

View File

@ -13,4 +13,34 @@ export const zhTWLocale: MDPowerLocaleData = {
warningTitle: '🚨 安全警告:',
warningText: '您的連線未使用 HTTPS 加密,可能導致內容洩露風險,無法存取加密內容。',
},
obsidian: {
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: '太長不看',
},
}

View File

@ -13,4 +13,34 @@ export const zhLocale: MDPowerLocaleData = {
warningTitle: '🚨 安全警告:',
warningText: '您的连接未使用HTTPS加密存在内容泄露风险无法访问加密内容。',
},
obsidian: {
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: '太长不看',
},
}

View File

@ -0,0 +1,9 @@
# 说明
兼容部分 obsidian 的 markdown 扩展语法。
**仅计划支持 obsidian 的官方扩展语法**。
- [x] wikiLink: `[[文件名]]` `[[文件名#标题]]` `[[文件名#标题#标题]]` `[[文件名#标题|别名]]`
- [x] embedLink: `![[文件名]]` `![[文件名#标题]]` `![[文件名#标题#标题]]`
- [x] comment: `%%注释%%`

View File

@ -0,0 +1,431 @@
/**
* obsidian callouts vuepress alerts
*
*
*
* 1. callouts vuepress alerts
* 2.
* 3.
*
* @see - https://obsidian.md/zh/help/callouts
*/
import type { PluginWithOptions } from 'markdown-it'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import type { MarkdownEnv } from 'vuepress/markdown'
import type { ObsidianCalloutOptions } from '../../shared/index.js'
import { capitalize, objectEntries, uniq } from '@pengzhanbo/utils'
import { ensureLeadingSlash } from '@vuepress/helper'
import { resolveLocalePath } from 'vuepress/shared'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
// 将 obsidian callout 映射到 vuepress alert 的类型
const calloutsToAlerts: Record<string, string[]> = {
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'],
}
const callouts = objectEntries(calloutsToAlerts).map(([k, v]) => [k, ...v]).flat()
const calloutAlias = objectEntries(calloutsToAlerts)
.reduce((acc, [k, v]) => {
v.forEach(alias => acc[alias] = k)
return acc
}, {} as Record<string, string>)
const calloutsDef: RuleBlock = (state, startLine, endLine, silent) => {
// if it's indented more than 3 spaces, it should be a code block
if (state.sCount[startLine] - state.blkIndent >= 4)
return false
let pos = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// check the block quote marker
if (state.src.charCodeAt(pos) !== 62 /* > */)
return false
let currentPos = pos + 1
let initial = state.sCount[startLine] + 1
let adjustTab = false
// skip one optional space after '>'
if (state.src.charCodeAt(currentPos) === 32 /* space */) {
// ' > [!tip] '
// ^ -- position start of line here:
currentPos++
initial++
}
else if (state.src.charCodeAt(currentPos) === 9 /* tab */) {
if ((state.bsCount[startLine] + initial) % 4 === 3) {
// ' >\t [!tip] '
// ^ -- position start of line here (tab has width===1)
currentPos++
initial++
}
else {
// ' >\t [!tip] '
// ^ -- position start of line here + shift bsCount slightly
// to make extra space appear
adjustTab = true
}
}
let offset = initial
while (currentPos < max) {
const ch = state.src.charCodeAt(currentPos)
if (ch === 9 /** \t */)
offset += 4 - ((offset + state.bsCount[startLine] + (adjustTab ? 1 : 0)) % 4)
else if (ch === 32 /** Space */)
offset++
else break
currentPos++
}
// skip blockquote
if (offset - initial >= 4)
return false
// the minimum length of an alert is 4 characters [!x]
if (max - currentPos < 4)
return false
// check opening marker '[!'
if (
state.src.charCodeAt(currentPos) !== 91
|| /* [ */ state.src.charCodeAt(currentPos + 1) !== 33 /* ! */
) {
return false
}
currentPos += 2
let typeName = ''
// find closing bracket ']'
while (currentPos < max) {
const char = state.src.charAt(currentPos)
if (char === ']')
break
typeName += char
currentPos++
}
if (currentPos === max)
return false
const type = typeName.toLowerCase()
if (!callouts.includes(type))
return false
// skip spaces after ']'
currentPos = state.skipSpaces(currentPos + 1)
// if there are non-space characters after ']', it's not a valid alert
// if (currentPos < max)
// return false
const titleContent = state.src.slice(currentPos, max)
const oldBMarks: number[] = []
const oldBSCount: number[] = []
const oldSCount: number[] = []
const oldTShift: number[] = []
const oldLineMax = state.lineMax
const oldParentType = state.parentType
const terminatorRules = [
state.md.block.ruler.getRules('blockquote'),
state.md.block.ruler.getRules('alert'),
].flat()
// @ts-expect-error: We are creating a new type called "alert"
state.parentType = 'alert'
// Search the end of the block
//
// Block ends with either:
// 1. an empty line outside:
// ```
// > test
//
// ```
// 2. an empty line inside:
// ```
// >
// test
// ```
// 3. another tag:
// ```
// > test
// - - -
// ```
let currentLine = startLine
let lastLineEmpty = false
let hasBodyContent = false
for (; currentLine < endLine; currentLine++) {
// check if it's outdented, i.e. it's inside list item and indented
// less than said list item:
//
// ```
// 1. anything
// > current blockquote
// 2. checking this line
// ```
const isOutdented = state.sCount[currentLine] < state.blkIndent
pos = state.bMarks[currentLine] + state.tShift[currentLine]
max = state.eMarks[currentLine]
// Case 1: line is not inside the blockquote, and this line is empty.
if (pos >= max)
break
if (state.src.charCodeAt(pos++) === 62 /* > */ && !isOutdented) {
// This line is inside the blockquote.
let spaceAfterMarker = false
// set offset past spaces and ">"
initial = state.sCount[currentLine] + 1
adjustTab = false
// skip one optional space after '>'
if (state.src.charCodeAt(pos) === 32 /* space */) {
// ' > test '
// ^ -- position start of line here:
pos++
initial++
spaceAfterMarker = true
}
else if (state.src.charCodeAt(pos) === 9 /* \t */) {
spaceAfterMarker = true
if ((state.bsCount[currentLine] + initial) % 4 === 3) {
// ' >\t test '
// ^ -- position start of line here (tab has width===1)
pos++
initial++
}
else {
// ' >\t test '
// ^ -- position start of line here + shift bsCount slightly
// to make extra space appear
adjustTab = true
}
}
offset = initial
if (!silent) {
oldBMarks.push(state.bMarks[currentLine])
state.bMarks[currentLine] = pos
}
while (pos < max) {
const ch = state.src.charCodeAt(pos)
if (ch === 9 /** \t */)
offset += 4 - ((offset + state.bsCount[currentLine] + (adjustTab ? 1 : 0)) % 4)
else if (ch === 32 /** Space */)
offset++
else break
pos++
}
lastLineEmpty = pos >= max
if (currentLine > startLine && !lastLineEmpty)
hasBodyContent = true
if (!silent) {
oldBSCount.push(state.bsCount[currentLine])
state.bsCount[currentLine] = state.sCount[currentLine] + 1 + (spaceAfterMarker ? 1 : 0)
oldSCount.push(state.sCount[currentLine])
state.sCount[currentLine] = offset - initial
oldTShift.push(state.tShift[currentLine])
state.tShift[currentLine] = pos - state.bMarks[currentLine]
}
continue
}
if (isOutdented)
break
// Case 2: line is not inside the blockquote, and the last line was empty.
if (lastLineEmpty)
break
// Case 3: another tag found.
let terminate = false
const terminateRuleLength = terminatorRules.length
for (let i = 0; i < terminateRuleLength; i++) {
const terminatorRule = terminatorRules[i]
if (terminatorRule(state, currentLine, endLine, true)) {
terminate = true
break
}
}
if (terminate) {
// Quirk to enforce "hard termination mode" for paragraphs;
// normally if you call `tokenize(state, startLine, nextLine)`,
// paragraphs will look below nextLine for paragraph continuation,
// but if blockquote is terminated by another tag, they shouldn't
state.lineMax = currentLine
if (state.blkIndent !== 0 && !silent) {
// state.blkIndent was non-zero, we now set it to zero,
// so we need to re-calculate all offsets to appear as
// if indent wasn't changed
oldBMarks.push(state.bMarks[currentLine])
oldBSCount.push(state.bsCount[currentLine])
oldSCount.push(state.sCount[currentLine])
oldTShift.push(state.tShift[currentLine])
state.sCount[currentLine] -= state.blkIndent
}
break
}
hasBodyContent = true
if (!silent) {
oldBMarks.push(state.bMarks[currentLine])
oldBSCount.push(state.bsCount[currentLine])
oldSCount.push(state.sCount[currentLine])
oldTShift.push(state.tShift[currentLine])
// A negative indentation means that this is a paragraph continuation
// we only set it if it's not the first line of the body
if (currentLine > startLine + 1)
state.sCount[currentLine] = -1
}
}
const restoreState = (): void => {
state.lineMax = oldLineMax
// Restore original tShift; this might not be necessary since the parser
// has already been here, but just to make sure we can do that.
for (let i = 0; i < oldTShift.length; i++) {
state.bMarks[i + startLine] = oldBMarks[i]
state.tShift[i + startLine] = oldTShift[i]
state.sCount[i + startLine] = oldSCount[i]
state.bsCount[i + startLine] = oldBSCount[i]
}
}
// If we didn't find any alert body, so we don't have a valid alert
if (startLine + 1 >= currentLine || !hasBodyContent) {
state.parentType = oldParentType
// If we are in silent mode, we don't need to restore the state
if (!silent)
restoreState()
return false
}
// from now we know that it's going to be a valid alert,
// so no point trying to find the end of it in silent mode
if (silent)
return true
const oldIndent = state.blkIndent
state.blkIndent = 0
const titleLines: [number, number] = [startLine, startLine + 1]
const contentLines: [number, number] = [startLine + 1, 0]
const openToken = state.push('alert_open', 'div', 1)
openToken.markup = type
openToken.attrJoin('class', `markdown-alert markdown-alert-${type}`)
openToken.map = contentLines
const titleToken = state.push('alert_title', '', 0)
titleToken.attrJoin('class', `markdown-alert-title`)
titleToken.markup = type
titleToken.content = typeName
titleToken.map = titleLines
titleToken.meta = { type, typeName, content: titleContent }
state.md.block.tokenize(state, startLine + 1, currentLine)
const closeToken = state.push('alert_close', 'div', -1)
closeToken.markup = type
contentLines[1] = state.line
state.blkIndent = oldIndent
state.parentType = oldParentType
restoreState()
return true
}
export const calloutPlugin: PluginWithOptions<ObsidianCalloutOptions> = (
md,
{
openRender,
closeRender,
titleRender,
locales = {},
} = {},
) => {
md.block.ruler.before(
'blockquote',
'alert',
calloutsDef,
{
alt: ['paragraph', 'reference', 'blockquote', 'list'],
},
)
md.renderer.rules.alert_open = openRender
?? ((tokens, index) => {
const type = tokens[index].markup
const actualType = calloutAlias[type] || type
const tag = actualType === 'details' ? 'details' : 'div'
return `<${tag} class="hint-container ${uniq([actualType, type]).join(' ')}">\n`
})
md.renderer.rules.alert_close = closeRender ?? ((tokens, index) => {
const type = tokens[index].markup
const actualType = calloutAlias[type] || type
return `</${actualType === 'details' ? 'details' : 'div'}>\n`
})
md.renderer.rules.alert_title = titleRender
?? ((tokens, index, _, env: MarkdownEnv): string => {
const { type, content } = tokens[index].meta
const actualType = calloutAlias[type] || type
const tag = actualType === 'details' ? 'summary' : 'p'
const title = content.replace(/^\s*[+-]?/, '').trim()
const rendered = title ? md.renderInline(title, cleanMarkdownEnv(env)) : ''
const relativePath = ensureLeadingSlash(env.filePathRelative ?? '')
const locale = resolveLocalePath(locales, relativePath)
return `<${tag}${tag === 'summary' ? '' : ' class="hint-container-title"'}>${
rendered || locales[locale]?.[type] || capitalize(type)
}</${tag}>\n`
})
}

View File

@ -0,0 +1,117 @@
/**
* comment obsidian 使 `%%` ,
* `%%`
*
* ```markdown
* %%%%
*
* %%
*
*
* %%
* ```
*
* @see - https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A
*/
import type { Markdown } from 'vuepress/markdown'
export function commentPlugin(md: Markdown): void {
md.inline.ruler.before(
'html_inline',
'obsidian_inline_comment',
(state, silent) => {
let found = false
const max = state.posMax
const start = state.pos
if (
state.src.charCodeAt(start) !== 0x25 // %
|| state.src.charCodeAt(start + 1) !== 0x25 // %
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return false
// - %%%%
if (max - start < 5)
return false
state.pos = start + 2
// 查找 %%
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x25
&& state.src.charCodeAt(state.pos + 1) === 0x25) {
found = true
break
}
state.md.inline.skipToken(state)
}
if (!found || start + 2 === state.pos) {
state.pos = start
return false
}
// found!
state.posMax = state.pos
state.pos = start + 2
const token = state.push('obsidian_inline_comment', '', 0)
token.content = state.src.slice(start + 2, state.pos)
token.markup = '%%'
token.map = [start, state.pos + 2]
state.pos = state.posMax + 2
state.posMax = max
return true
},
)
md.block.ruler.before(
'html_block',
'obsidian_block_comment',
(state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine]
// check starts with %%
if (state.src.charCodeAt(start) !== 0x25 // %
|| state.src.charCodeAt(start + 1) !== 0x25 // %
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return true
let line = startLine
let content = ''
let found = false
// 查找 %%
while (++line < endLine) {
if (state.src.slice(state.bMarks[line], state.eMarks[line]).trim() === '%%') {
found = true
break
}
content += `${state.src.slice(state.bMarks[line], state.eMarks[line])}\n`
}
if (!found)
return false
const token = state.push('obsidian_block_comment', '', 0)
token.content = content
token.markup = '%%'
token.map = [startLine, line + 1]
state.line = line + 1
return true
},
)
md.renderer.rules.obsidian_inline_comment = () => ''
md.renderer.rules.obsidian_block_comment = () => ''
}

View File

@ -0,0 +1,392 @@
/**
* Embed Link obsidian markdown
*
* - ![[]] ![[#]] ![[##]]
* - ![[]]
* - ![[]] ![[|width]] ![[|widthxheight]]
* - ![[pdf]] ![[pdf#page=1#height=300]]
* - ![[]]
* - ![[]]
*
* @see - https://obsidian.md/zh/help/embeds
* @see - https://obsidian.md/zh/help/file-formats
*
*
*/
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
import type { App } from 'vuepress'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { attempt } from '@pengzhanbo/utils'
import grayMatter from 'gray-matter'
import Token from 'markdown-it/lib/token.mjs'
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
import { fs, hash, path } from 'vuepress/utils'
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
import { resolvePaths } from '../enhance/links.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
import { parseRect } from '../utils/parseRect.js'
import { slugify } from '../utils/slugify.js'
import { findFirstPage } from './findFirstPage.js'
interface EmbedLinkMeta {
filename: string
hashes: string[]
settings: string
isInline: boolean
}
const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm']
const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc']
const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`)
const blockEmbedLinkDef: RuleBlock = (state, startLine, endLine, silent) => {
const start = state.bMarks[startLine] + state.tShift[startLine]
const max = state.eMarks[startLine]
// - ![[]]
if (max - start < 6)
return false
// 是否以 `![[` 开头
if (
state.src.charCodeAt(start) !== 0x21 // \!
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|| state.src.charCodeAt(start + 2) !== 0x5B // [
) {
return false
}
const line = state.src.slice(start, max).trim()
// 是否以 `]]` 结尾
if (
line.charCodeAt(line.length - 1) !== 0x5D // ]
|| line.charCodeAt(line.length - 2) !== 0x5D // ]
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return true
// ![[xxxx]]
// ^^^^ <- content
const content = line.slice(3, -2).trim()
genEmbedAsset(state, content)
state.line = startLine + 1
return true
}
const inlineEmbedLinkDef: RuleInline = (state, silent) => {
let found = false
const max = state.posMax
const start = state.pos
if (
state.src.charCodeAt(start) !== 0x21 // \!
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|| state.src.charCodeAt(start + 2) !== 0x5B // [
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return false
// - ![[]]
if (max - start < 6)
return false
state.pos = start + 2
// 查找 ]]
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x5D
&& state.src.charCodeAt(state.pos + 1) === 0x5D) {
found = true
break
}
state.md.inline.skipToken(state)
}
if (!found || start + 2 === state.pos) {
state.pos = start
return false
}
// [[xxxx]]
// ^^^^ <- content
const content = state.src.slice(start + 3, state.pos).trim()
// found!
state.posMax = state.pos
state.pos = start + 3
genEmbedAsset(state, content, true)
state.pos = state.posMax + 2
state.posMax = max
return true
}
export function embedLinkPlugin(md: Markdown, app: App): void {
md.block.ruler.before(
'import_code',
'obsidian_block_embed_link',
blockEmbedLinkDef,
{ alt: ['paragraph', 'reference', 'blockquote', 'list'] },
)
md.inline.ruler.before('emphasis', 'obsidian_inline_embed_link', inlineEmbedLinkDef)
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
const token = tokens[idx]
const { filename, hashes, settings, isInline } = token.meta as EmbedLinkMeta
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
// 行内规则,解析为链接
if (isInline && pagePath) {
const anchor = hashes.at(-1)
const slug = anchor ? `#${slugify(anchor)}` : ''
const { absolutePath, relativePath } = resolvePaths(
pagePath,
env.base || '/',
env.filePathRelative ?? null,
)
;(env.links ??= []).push({
raw: pagePath,
absolute: absolutePath,
relative: relativePath,
})
return `<VPLink href="${ensureLeadingSlash(pagePath)}${slug}">${md.utils.escapeHtml(settings) || (hashes.length ? `<template #after-text>${md.utils.escapeHtml(` > ${hashes.join(' > ')}`)}</template>` : '')}</VPLink>`
}
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
if (pagePath) {
const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8'))
if (error) {
console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`)
return ''
}
const { content: rawContent } = grayMatter(markdown)
if (!rawContent) {
console.warn(`[embedLinkPlugin] file ${pagePath} is empty`)
return ''
}
const content = extractContentByHeadings(rawContent, hashes)
pagePath && (env.importedFiles ??= []).push(pagePath)
return md.render(content, cleanMarkdownEnv(env))
}
// 其他资源,解析为链接
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
const anchor = hashes.at(-1)
const slug = anchor ? `#${slugify(anchor)}` : ''
const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : ''))
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
md.utils.escapeHtml(text)
}</a>`
}
}
function genEmbedAsset(state: StateBlock | StateInline, content: string, isInline = false): void {
const [file, settings] = content.split('|').map(x => x.trim())
const [filename, ...hashes] = file.trim().split('#').map(x => x.trim())
const extname = path.extname(filename).toLowerCase()
// 渲染为 图片
if (EXTENSION_IMAGES.includes(extname)) {
const token = state.push('image', 'img', 1)
token.content = filename
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('alt', filename)
if (settings) {
const [width, height] = settings.split('x').map(x => x.trim())
const styles: string[] = []
if (width)
styles.push(`width: ${parseRect(width)}`)
if (height)
styles.push(`height: ${parseRect(height)}`)
token.attrSet('style', styles.join(';'))
}
const text = new Token('text', '', 0)
text.content = filename
token.children = [text]
}
// 渲染为音频
else if (EXTENSION_AUDIOS.includes(extname)) {
const token = state.push('audio_open', 'audio', 1)
token.content = filename
token.attrSet('controls', 'true')
token.attrSet('preload', 'metadata')
token.attrSet('aria-label', filename)
const sourceToken = state.push('source_open', 'source', 1)
sourceToken.attrSet('src', resolveFilenameToAssetPath(filename))
state.push('audio_close', 'audio', -1)
}
// 渲染为视频,使用 ArtPlayer
else if (EXTENSION_VIDEOS.includes(extname)) {
const token = state.push('video_artPlayer_open', 'ArtPlayer', 1)
const type = extname.slice(1)
checkSupportType(type)
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('type', type)
token.attrSet('width', '100%')
token.attrSet(':fullscreen', 'true')
token.attrSet(':flip', 'true')
token.attrSet(':playback-rate', 'true')
token.attrSet(':aspect-ratio', 'true')
token.attrSet(':setting', 'true')
token.attrSet(':pip', 'true')
token.attrSet(':volume', '0.75')
token.content = filename
state.push('video_artPlayer_close', 'ArtPlayer', -1)
}
// 渲染为 pdf
else if (extname === '.pdf') {
const token = state.push('pdf_open', 'PDFViewer', 1)
token.attrSet('src', resolveFilenameToAssetPath(filename))
token.attrSet('width', '100%')
for (const hash of hashes) {
const [key, value] = hash.split('=').map(x => x.trim())
token.attrSet(key, value)
}
token.content = filename
state.push('pdf_close', 'PDFViewer', -1)
}
// 非受支持的外部资源,渲染为链接
else if (isLinkHttp(filename) || (extname && extname !== '.md')) {
const token = state.push('link_open', 'a', 1)
token.attrSet('href', filename)
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
token.content = filename
const content = state.push('text', '', 0)
content.content = filename
state.push('link_close', 'a', -1)
}
// 剩余情况,如内部的 markdown 文件
// 在 obsidian_embed_link renderer rule 中处理
else {
const token = state.push('obsidian_embed_link', '', 0)
token.markup = '![[]]'
token.meta = {
filename: filename.trim(),
hashes: hashes.map(hash => hash.trim()),
settings: settings?.trim(),
isInline,
} as EmbedLinkMeta
token.content = content
}
}
function resolveFilenameToAssetPath(filename: string): string {
if (isLinkHttp(filename) || filename[0] === '.' || filename[0] === '/') {
return filename
}
return `/${filename}`
}
interface ParsedHeading {
lineIndex: number
level: number
text: string
}
// 支持: ## 标题 {#id .class key=value} 或 ## 标题 {#id}
const HEADING_HASH_REG = /^#+/
const HEADING_ATTRS_REG = /(?:\{[^}]*\})?$/
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 = hash(matched)
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) {
console.warn(`No heading found for ${headings.join(' > ')}`)
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] ?? '')
}

View File

@ -0,0 +1,42 @@
import type { App } from 'vuepress'
import { removeLeadingSlash } from '@vuepress/helper'
import { path, tinyglobby } from 'vuepress/utils'
const pagePaths: string[] = []
export function initPagePaths(app: App) {
pagePaths.length = 0
pagePaths.push(...tinyglobby.globSync(app.options.pagePatterns, {
cwd: app.dir.source(),
ignore: ['**/node_modules/**', '**/.vuepress/**'],
}))
sortPagePaths()
}
export function updatePagePaths(filepath: string, type: 'create' | 'delete') {
if (!filepath)
return
if (type === 'create') {
pagePaths.push(filepath)
}
if (type === 'delete' && pagePaths.includes(filepath)) {
pagePaths.splice(pagePaths.indexOf(filepath), 1)
}
sortPagePaths()
}
export function findFirstPage(filename: string, currentPath: string) {
const dirname = path.dirname(currentPath)
let filepath = filename[0] === '.' ? path.join(dirname, filename) : removeLeadingSlash(filename)
filepath = filepath.slice(-1) === '/' ? `${filepath}/README.md` : filepath
filepath = path.extname(filepath) ? filepath : `${filepath}.md`
return pagePaths.find(pagePath => pagePath === filepath || pagePath.endsWith(filepath))
}
function sortPagePaths() {
pagePaths.sort((a, b) => {
const al = a.split('/').length
const bl = b.split('/').length
return al - bl
})
}

View File

@ -0,0 +1,45 @@
import type { App } from 'vuepress'
import type { Markdown } from 'vuepress/markdown'
import type { MarkdownPowerPluginOptions, MDPowerLocaleData, ObsidianLocaleData } from '../../shared/index.js'
import { deepAssign, type ExactLocaleConfig } from '@vuepress/helper'
import { isPlainObject } from 'vuepress/shared'
import { findLocales } from '../utils/findLocales.js'
import { calloutPlugin } from './callouts.js'
import { commentPlugin } from './comment.js'
import { embedLinkPlugin } from './embedLink.js'
import { initPagePaths } from './findFirstPage.js'
import { wikiLinkPlugin } from './wikiLink.js'
export * from './findFirstPage.js'
export function obsidianPlugin(
app: App,
md: Markdown,
options: MarkdownPowerPluginOptions,
locales: ExactLocaleConfig<MDPowerLocaleData>,
) {
if (options.obsidian === false)
return
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
const obsidianLocales = findLocales(locales, 'obsidian')
initPagePaths(app)
if (obsidian.wikiLink !== false)
wikiLinkPlugin(md)
if (obsidian.embedLink !== false)
embedLinkPlugin(md, app)
if (obsidian.comment !== false)
commentPlugin(md)
if (obsidian.callout !== false) {
const { locales = {}, ...options } = isPlainObject(obsidian.callout) ? obsidian.callout : {}
calloutPlugin(md, {
...options,
locales: deepAssign<Record<string, ObsidianLocaleData>>({}, obsidianLocales, locales),
})
}
}

View File

@ -0,0 +1,127 @@
/**
* Wiki Link obsidian markdown
*
* [[]] [[#]] [[##]] [[#|]]
*
* @see - https://obsidian.md/zh/help/links
*
*
*/
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
import { path } from 'vuepress/utils'
import { resolvePaths } from '../enhance/links.js'
import { slugify } from '../utils/slugify.js'
import { findFirstPage } from './findFirstPage.js'
interface WikiLinkMeta {
filename: string
alias: string
titles: string[]
}
const wikiLinkDef: RuleInline = (state, silent) => {
let found = false
const max = state.posMax
const start = state.pos
if (
state.src.charCodeAt(start) !== 0x5B
|| state.src.charCodeAt(start + 1) !== 0x5B
) {
return false
}
/* istanbul ignore if -- @preserve */
if (silent)
return false
// - [[]]
if (max - start < 5)
return false
state.pos = start + 2
// 查找 ]]
while (state.pos < max) {
if (state.src.charCodeAt(state.pos) === 0x5D
&& state.src.charCodeAt(state.pos + 1) === 0x5D) {
found = true
break
}
state.md.inline.skipToken(state)
}
if (!found || start + 2 === state.pos) {
state.pos = start
return false
}
// [[xxxx]]
// ^^^^ <- content
const content = state.src.slice(start + 2, state.pos).trim()
// found!
state.posMax = state.pos
state.pos = start + 2
const [file, alias] = content.split('|')
const [filename, ...titles] = file.trim().split('#')
const token = state.push('obsidian_wiki_link', '', 0)
token.markup = '[[]]'
token.meta = {
filename: filename.trim(),
titles: titles.map(title => title.trim()),
alias: alias?.trim(),
} as WikiLinkMeta
token.content = content
state.pos = state.posMax + 2
state.posMax = max
return true
}
export function wikiLinkPlugin(md: Markdown) {
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
const token = tokens[idx]
const { filename, titles, alias } = token.meta as WikiLinkMeta
const anchor = titles.at(-1)
const slug = anchor ? `#${slugify(anchor)}` : ''
// external link
if (isLinkHttp(filename)) {
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
return `<a href="${filename}${slug}" target="_blank" rel="noopener noreferrer">${
md.utils.escapeHtml(text)
}</a>`
}
// internal hash link
if (!filename) { // internal page hash link
return `<VPLink href="${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
}
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
if (pagePath) {
const { absolutePath, relativePath } = resolvePaths(
pagePath,
env.base || '/',
env.filePathRelative ?? null,
)
;(env.links ??= []).push({
raw: pagePath,
absolute: absolutePath,
relative: relativePath,
})
return `<VPLink href="${ensureLeadingSlash(pagePath)}${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
}
// other asset url
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
md.utils.escapeHtml(text)
}</a>`
}
}

View File

@ -13,6 +13,7 @@ import { linksPlugin } from './enhance/links.js'
import { iconPlugin } from './icon/index.js'
import { inlineSyntaxPlugin } from './inline/index.js'
import { LOCALE_OPTIONS } from './locales/index.js'
import { obsidianPlugin, updatePagePaths } from './obsidian/index.js'
import { prepareConfigFile } from './prepareConfigFile.js'
import { provideData } from './provideData.js'
@ -111,6 +112,8 @@ export function markdownPowerPlugin(
await containerPlugin(app, md, options, locales)
await imageSizePlugin(app, md, options.imageSize)
obsidianPlugin(app, md, options, locales)
},
onPrepared: async () => {
@ -131,6 +134,13 @@ export function markdownPowerPlugin(
if (options.codeTree)
extendsPageWithCodeTree(page)
},
onPageUpdated(_app, type, newPage, oldPage) {
if (type === 'create')
updatePagePaths(newPage?.filePathRelative ?? '', 'create')
if (type === 'delete')
updatePagePaths(oldPage?.filePathRelative ?? newPage?.filePathRelative ?? '', 'delete')
},
}
}
}

View File

@ -0,0 +1,26 @@
// eslint-disable-next-line no-control-regex
const rControl = /[\u0000-\u001F]/g
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g
const rCombining = /[\u0300-\u036F]/g
/**
* Default slugification function
*/
export function slugify(str: string): string {
return str
.normalize('NFKD')
// Remove accents
.replace(rCombining, '')
// Remove control characters
.replace(rControl, '')
// Replace special characters
.replace(rSpecial, '-')
// Remove continuos separators
.replace(/-{2,}/g, '-')
// Remove prefixing and trailing separators
.replace(/^-+|-+$/g, '')
// ensure it doesn't start with a number
.replace(/^(\d)/, '_$1')
// lowercase
.toLowerCase()
}

View File

@ -9,6 +9,7 @@ export * from './icon.js'
export * from './jsfiddle.js'
export * from './locale.js'
export * from './npmTo.js'
export * from './obsidian.js'
export * from './pdf.js'
export * from './plot.js'
export * from './plugin.js'

View File

@ -1,5 +1,6 @@
import type { LocaleData } from 'vuepress'
import type { EncryptSnippetLocale } from './encrypt'
import type { ObsidianLocaleData } from './obsidian'
/**
* Markdown Power Plugin Locale Data
@ -19,6 +20,12 @@ export interface MDPowerLocaleData extends LocaleData {
*
*/
encrypt?: EncryptSnippetLocale
/**
* Obsidian locale data
*
* Obsidian
*/
obsidian?: ObsidianLocaleData
}
/**

View File

@ -0,0 +1,46 @@
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
export interface ObsidianOptions {
wikiLink?: boolean
embedLink?: boolean
comment?: boolean
callout?: boolean | ObsidianCalloutOptions
}
export interface ObsidianCalloutOptions {
locales?: Record<string, ObsidianLocaleData>
openRender?: RenderRule
closeRender?: RenderRule
titleRender?: RenderRule
}
export interface ObsidianLocaleData {
note?: string
quote?: string
cite?: string
tip?: string
hint?: string
info?: string
todo?: string
success?: string
check?: string
done?: string
warning?: string
question?: string
help?: string
faq?: string
caution?: string
attention?: string
failure?: string
fail?: string
missing?: string
danger?: string
error?: string
bug?: string
important?: string
example?: string
details?: string
abstract?: string
summary?: string
tldr?: string
}

View File

@ -18,7 +18,7 @@ export interface PDFTokenMeta extends SizeOptions {
*
*
*/
page?: number
page?: number | string
/**
* Whether to hide toolbar
*

View File

@ -9,6 +9,7 @@ import type { IconOptions } from './icon.js'
import type { MDPowerLocaleData } from './locale.js'
import type { MarkOptions } from './mark.js'
import type { NpmToOptions } from './npmTo.js'
import type { ObsidianOptions } from './obsidian.js'
import type { PDFOptions } from './pdf.js'
import type { PlotOptions } from './plot.js'
import type { ReplOptions } from './repl.js'
@ -406,5 +407,12 @@ export interface MarkdownPowerPluginOptions {
*/
imageSize?: boolean | 'local' | 'all'
/**
* obsidian markdown
*
* @default false
*/
obsidian?: boolean | ObsidianOptions
locales?: LocaleConfig<MDPowerLocaleData>
}

View File

@ -33,6 +33,26 @@ export interface QRCodeProps {
*
*/
text?: string
/**
* Logo image
*
* logo
*/
logo?: string
/**
* Logo image size
*
* Unit: percentage of QR code width
*
* logo
*
*
*
* @default 0.2
*/
logoSize?: string
/**
* QR code width
*
@ -44,7 +64,6 @@ export interface QRCodeProps {
* Display mode
* - img: Display QR code as image
* - card: Display as card with left-right layout, QR code on left, title + content on right
* @default 'img'
*
*
* - img: 以图片的形式显示二维码
@ -62,27 +81,15 @@ export interface QRCodeProps {
/**
* QR code alignment
* @default 'left'
*
*
* @default 'left'
*/
align?: 'left' | 'center' | 'right'
/**
* Whether to render as SVG format
* Default output is PNG format dataURL
* @default false
*
* SVG
* PNG dataURL
* @default false
*/
svg?: boolean
/**
* Error correction level.
* Possible values: Low, Medium, Quartile, High, corresponding to L, M, Q, H.
* @default 'M'
*
*
* LMQH
@ -109,7 +116,6 @@ export interface QRCodeProps {
mask?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
/**
* Define how wide the quiet zone should be.
* @default 4
*
*
* @default 4
@ -125,7 +131,6 @@ export interface QRCodeProps {
/**
* Color of dark modules. Value must be in hexadecimal format (RGBA).
* Note: Dark should always be darker than light module color.
* @default '#000000ff'
*
* RGBA
*
@ -136,7 +141,6 @@ export interface QRCodeProps {
/**
* Color of light modules. Value must be in hexadecimal format (RGBA).
* Note: Light should always be lighter than dark module color.
* @default '#ffffffff'
*
* RGBA
*

View File

@ -2,7 +2,7 @@ import { defineConfig, type UserConfig } from 'tsdown'
import { argv } from '../../scripts/tsdown-args'
const config = [
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts', 'mark.ts', 'decrypt.ts'] },
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts', 'mark.ts', 'decrypt.ts', 'qrcode.ts'] },
{ dir: 'utils', files: ['http.ts', 'link.ts', 'sleep.ts'] },
{ dir: '', files: ['index.ts', 'options.ts'] },
]

View File

@ -1,7 +1,7 @@
{
"name": "@vuepress-plume/plugin-search",
"type": "module",
"version": "1.0.0-rc.193",
"version": "1.0.0-rc.198",
"description": "The Plugin for VuePress 2 - local search",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

View File

@ -165,6 +165,14 @@ export async function prepareSearchIndex({
}
}
let updateQueue = Promise.resolve()
async function queueUpdateIndexFile({ page, isSearchable, searchOptions }: UpdateSearchIndexOptions) {
updateQueue = updateQueue
.then(() => indexFile(page, searchOptions, isSearchable))
return updateQueue
}
/**
* Handle search index update when a page is modified.
*
@ -187,7 +195,8 @@ export async function onSearchIndexUpdated(
if (isSearchable && !isSearchable(page))
return
await indexFile(page, searchOptions, isSearchable)
// FIXME: onPageUpdated 存在竞态问题,当前使用异步队列避免
await queueUpdateIndexFile({ page, isSearchable, searchOptions })
await writeTemp(app)
}
@ -297,8 +306,11 @@ async function indexFile(page: Page, options: SearchIndexOptions['searchOptions'
${page.contentRendered}`
const sections = splitPageIntoSections(html)
if (cache && cache.length)
index.removeAll(cache)
try {
if (cache && cache.length)
index.removeAll(cache)
}
catch {}
// add sections to the locale index
for await (const section of sections) {

View File

@ -3,7 +3,7 @@ import type { SearchPluginOptions } from '../shared/index.js'
import { addViteOptimizeDepsInclude, getFullLocaleConfig } from '@vuepress/helper'
import { getDirname, path } from 'vuepress/utils'
import { SEARCH_LOCALES } from './locales/index.js'
import { onSearchIndexRemoved, onSearchIndexUpdated, prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
import { /* onSearchIndexRemoved, onSearchIndexUpdated, */ prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
const __dirname = getDirname(import.meta.url)
@ -71,16 +71,16 @@ export function searchPlugin({
}
},
onPageUpdated: async (app, type, page) => {
if (!page?.filePathRelative)
return
// onPageUpdated: async (app, type, page) => {
// if (!page?.filePathRelative)
// return
if (type === 'create' || type === 'update') {
await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
}
else if (type === 'delete') {
await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
}
},
// if (type === 'create' || type === 'update') {
// await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
// }
// else if (type === 'delete') {
// await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
// }
// },
})
}

3348
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
catalogMode: prefer
ignoreWorkspaceRootCheck: true
shamefullyHoist: true
shellEmulator: true
strictPeerDependencies: false
@ -9,85 +11,83 @@ trustPolicyExclude:
- cached-factory
- memfs
- semver
packages:
- docs
- theme
- cli
- plugins/*
- examples/*
patchedDependencies:
floating-vue: patches/floating-vue.patch
catalogs:
dev:
'@commitlint/cli': ^20.5.0
'@commitlint/cli': ^20.5.2
'@commitlint/config-conventional': ^20.5.0
'@lunariajs/core': ^0.1.1
'@pengzhanbo/eslint-config-vue': ^2.2.0
'@pengzhanbo/stylelint-config': ^2.2.0
'@pengzhanbo/eslint-config-vue': ^2.4.0
'@pengzhanbo/stylelint-config': ^2.4.0
'@simonwep/pickr': ^1.9.1
'@types/express': ^5.0.6
'@types/js-yaml': ^4.0.9
'@types/less': ^3.0.8
'@types/markdown-it': ^14.1.2
'@types/minimist': ^1.2.5
'@types/node': ^25.5.0
'@types/picomatch': ^4.0.2
'@types/node': ^25.6.0
'@types/picomatch': ^4.0.3
'@types/qrcode': ^1.5.6
'@types/stylus': ^0.48.43
'@types/three': ^0.183.1
'@types/three': ^0.184.0
'@types/webpack-env': ^1.18.8
'@vitest/coverage-v8': ^4.1.2
'@vitest/coverage-v8': ^4.1.5
bumpp: ^11.0.1
commitizen: ^4.3.1
conventional-changelog-cli: ^5.0.0
cpx2: ^8.0.0
conventional-changelog: ^7.2.0
conventional-changelog-angular: ^8.3.1
cpx2: ^8.0.2
cross-env: 7.0.3
cz-conventional-changelog: ^3.3.0
eslint: ^10.1.0
eslint: ^10.2.1
http-server: ^14.1.1
husky: ^9.1.7
less: ^4.6.4
lint-staged: ^16.4.0
markdown-it: ^14.1.1
memfs: ^4.57.1
memfs: ^4.57.2
mermaid: ^11.14.0
minimist: ^1.2.8
postcss: ^8.5.8
postcss: ^8.5.10
rimraf: ^6.1.3
stylelint: ^17.6.0
stylelint: ^17.9.0
stylus: ^0.64.0
tsconfig-vuepress: ^7.0.0
tsdown: ^0.21.7
typescript: ^5.9.3
vite: ^8.0.3
vitest: ^4.1.2
wait-on: ^9.0.4
tsdown: ^0.21.10
typescript: ^6.0.3
vite: ^8.0.10
vitest: ^4.1.5
wait-on: ^9.0.5
peer:
'@eslint-community/eslint-utils': ^4.9.1
'@iconify/json': ^2.2.458
'@iconify/json': ^2.2.466
'@mathjax/src': ^4.1.1
'@pinyin-pro/data': ^1.3.1
'@typescript-eslint/types': ^8.58.0
'@typescript-eslint/utils': ^8.58.0
'@typescript-eslint/types': ^8.59.0
'@typescript-eslint/utils': ^8.59.0
artplayer: ^5.4.0
dashjs: ^5.1.1
gsap: ^3.14.2
hls.js: ^1.6.15
gsap: ^3.15.0
hls.js: ^1.6.16
mpegts.js: 1.7.3
ogl: ^1.0.11
pinyin-pro: ^3.28.0
postprocessing: ^6.39.0
pinyin-pro: ^3.28.1
postprocessing: ^6.39.1
pyodide: ^0.29.3
sass: ^1.98.0
sass-embedded: ^1.98.0
sass: ^1.99.0
sass-embedded: ^1.99.0
swiper: ^12.1.3
three: ^0.183.2
three: ^0.184.0
prod:
'@clack/prompts': ^1.2.0
'@iconify/utils': ^3.1.0
'@iconify/utils': ^3.1.1
'@iconify/vue': ^5.0.0
'@mdit/plugin-attrs': ^0.25.2
'@mdit/plugin-footnote': ^0.23.2
@ -96,7 +96,7 @@ catalogs:
'@mdit/plugin-sup': ^0.24.2
'@mdit/plugin-tab': ^0.24.2
'@mdit/plugin-tasklist': ^0.23.2
'@pengzhanbo/utils': ^3.3.1
'@pengzhanbo/utils': ^3.5.1
'@vueuse/core': ^14.2.1
'@vueuse/integrations': ^14.2.1
cac: ^7.0.0
@ -104,17 +104,17 @@ catalogs:
chokidar: 5.0.0
dayjs: ^1.11.20
echarts: ^6.0.0
esbuild: ^0.27.5
esbuild: ^0.28.0
flowchart.ts: ^3.0.1
focus-trap: ^8.0.1
focus-trap: ^8.1.0
gray-matter: ^4.0.3
handlebars: ^4.7.9
hash-wasm: ^4.12.0
image-size: ^2.0.2
js-yaml: ^4.1.1
katex: ^0.16.44
katex: ^0.16.45
local-pkg: ^1.1.2
lru-cache: ^11.2.7
lru-cache: ^11.3.5
mark.js: ^8.11.1
markdown-it-cjk-friendly: ^2.0.2
markdown-it-container: ^4.0.0
@ -123,7 +123,7 @@ catalogs:
markmap-view: ^0.18.12
minisearch: ^7.2.0
nano-spawn: ^2.1.0
nanoid: ^5.1.7
nanoid: ^5.1.9
os-locale: ^8.0.0
p-map: ^7.0.4
package-manager-detector: ^1.6.0
@ -134,8 +134,8 @@ catalogs:
sort-package-json: ^3.6.1
tm-grammars: ^1.31.15
tm-themes: ^1.12.2
vue: ^3.5.31
vue-router: ^5.0.4
vue: ^3.5.33
vue-router: ^5.0.6
vuepress:
'@vuepress/bundler-vite': 2.0.0-rc.28
'@vuepress/helper': 2.0.0-rc.128
@ -160,7 +160,6 @@ catalogs:
'@vuepress/plugin-watermark': 2.0.0-rc.128
'@vuepress/shiki-twoslash': 2.0.0-rc.128
vuepress: 2.0.0-rc.28
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js

View File

@ -46,10 +46,10 @@ You can install these skills into your project or globally using the `skills` CL
```bash
# Install into your current project (recommended for teams)
npx skills add https://github.com/pengzhanbo/vuepress-plume
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume
# Install specific skill only
npx skills add https://github.com/pengzhanbo/vuepress-plume --skill vuepress-plume-config
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume --skill vuepress-plume-config
```
2. **Verify Installation:**
@ -72,7 +72,7 @@ Claude Code automatically detects skills in the `.claude/skills` directory or th
```bash
# Install for Claude Code specifically
npx skills add https://github.com/pengzhanbo/vuepress-plume -a claude-code
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a claude-code
```
**Configuration:**
@ -86,7 +86,7 @@ OpenCode supports the open agent skills standard.
```bash
# Install for OpenCode
npx skills add https://github.com/pengzhanbo/vuepress-plume -a opencode
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a opencode
```
**Usage:**

View File

@ -46,10 +46,10 @@
```bash
# 安装到当前项目(团队协作推荐)
npx skills add https://github.com/pengzhanbo/vuepress-plume
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume
# 仅安装特定技能
npx skills add https://github.com/pengzhanbo/vuepress-plume --skill vuepress-plume-config
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume --skill vuepress-plume-config
```
2. **验证安装:**
@ -72,7 +72,7 @@ Claude Code 会自动检测 `.claude/skills` 目录或配置的标准 `skills`
```bash
# 专为 Claude Code 安装
npx skills add https://github.com/pengzhanbo/vuepress-plume -a claude-code
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a claude-code
```
**配置:**
@ -86,7 +86,7 @@ OpenCode 支持开放代理技能open agent skills标准。
```bash
# 为 OpenCode 安装
npx skills add https://github.com/pengzhanbo/vuepress-plume -a opencode
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a opencode
```
**使用:**

View File

@ -1,7 +1,7 @@
{
"name": "vuepress-theme-plume",
"type": "module",
"version": "1.0.0-rc.193",
"version": "1.0.0-rc.198",
"description": "A Blog&Document Theme for VuePress 2.0",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",

View File

@ -2,6 +2,7 @@
import { computed, toRef } from 'vue'
import { useRouter, withBase } from 'vuepress/client'
import { useData, useLink } from '../composables/index.js'
import { resolveNavLink } from '../utils/index.js'
const props = defineProps<{
tag?: string
@ -19,6 +20,13 @@ const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))
const { link, isExternal, isExternalProtocol } = useLink(toRef(props, 'href'), toRef(props, 'target'))
const resolvedText = computed(() => {
if (props.text || isExternal.value || !link.value)
return props.text
const { text } = resolveNavLink(link.value)
return text
})
function linkTo(e: Event) {
if (!isExternal.value && link.value) {
e.preventDefault()
@ -36,9 +44,8 @@ function linkTo(e: Event) {
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
@click="linkTo($event)"
>
<slot>
{{ text || href }}
</slot>
<slot>{{ resolvedText || href }}</slot>
<slot name="after-text" />
<span v-if="isExternal && !noIcon" class="visually-hidden">
{{ theme.openNewWindowText || '(Open in new window)' }}
</span>

View File

@ -108,6 +108,7 @@ function onItemInteraction(e: MouseEvent | Event) {
:aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${item.text}`"
:aria-expanded="!collapsed"
tabindex="-1"
@click.capture="item.link && toggle()"
>
<span class="vpi-chevron-right caret-icon" />
</button>

View File

@ -56,7 +56,7 @@ export function useLink(
const maybeIsExternal = computed(() => {
const link = toValue(href)
const rawTarget = toValue(target)
if (!link)
if (!link || link[0] === '#')
return false
if (rawTarget === '_blank' || isLinkExternal(link))
return true
@ -70,8 +70,12 @@ export function useLink(
if (!link || maybeIsExternal.value)
return link
if (link[0] === '#')
return page.value.path + link
const currentPath = page.value.filePathRelative ? `/${page.value.filePathRelative}` : undefined
const path = resolveRouteFullPath(link, currentPath)
if (path.includes('#')) {
// Compare path + anchor with current route path
// Convert to anchor link to avoid page refresh

View File

@ -189,6 +189,12 @@ const copyPageText = computed(() => {
width: fit-content;
}
@media print {
.vp-page-context-menu {
display: none;
}
}
.page-context-button {
height: 32px;
overflow: hidden;

View File

@ -211,7 +211,7 @@
* Table
* -------------------------------------------------------------------------- */
.vp-doc table {
display: table;
display: block;
margin: 20px 0;
overflow-x: auto;
border-collapse: collapse;

View File

@ -1,177 +1,81 @@
/* stylelint-disable no-descending-specificity */
:root {
--vp-hint-container-text: var(--vp-c-text-2);
--vp-hint-container-font-size: var(--vp-custom-block-font-size);
--vp-hint-container-line-height: var(--vp-custom-block-line-height);
--vp-hint-container-border: transparent;
--vp-hint-container-bg: transparent;
--vp-hint-container-link-text: var(--vp-c-brand-1);
--vp-hint-container-link-hover: var(--vp-c-brand-2);
--vp-hint-container-code-text: var(--vp-c-brand-1);
--vp-hint-container-code-bg: var(--vp-code-bg);
--vp-hint-container-icon: none;
}
.vp-doc .hint-container {
padding: 16px 16px 8px;
margin: 16px auto;
font-size: var(--vp-custom-block-font-size);
line-height: var(--vp-custom-block-line-height);
color: var(--vp-c-text-2);
border: 1px solid transparent;
font-size: var(--vp-hint-container-font-size);
line-height: var(--vp-hint-container-line-height);
color: var(--vp-hint-container-text);
background-color: var(--vp-hint-container-bg);
border: 1px solid;
border-color: var(--vp-hint-container-border);
border-radius: 8px;
}
.vp-doc .hint-container.info {
color: var(--vp-custom-block-info-text);
background-color: var(--vp-custom-block-info-bg);
border-color: var(--vp-custom-block-info-border);
.vp-doc .hint-container a {
color: var(--vp-hint-container-link-text);
}
.vp-doc .hint-container.info a,
.vp-doc .hint-container.info code {
color: var(--vp-c-brand-1);
.vp-doc .hint-container code {
font-size: var(--vp-custom-block-code-font-size);
color: var(--vp-hint-container-code-text);
background-color: var(--vp-hint-container-code-bg);
}
.vp-doc .hint-container.info a:hover,
.vp-doc .hint-container.info a:hover > code {
color: var(--vp-c-brand-2);
.vp-doc .hint-container a:hover,
.vp-doc .hint-container a:hover > code {
color: var(--vp-hint-container-link-hover);
}
.vp-doc .hint-container.info code {
background-color: var(--vp-custom-block-info-code-bg);
}
.vp-doc .hint-container.note {
color: var(--vp-custom-block-note-text);
background-color: var(--vp-custom-block-note-bg);
border-color: var(--vp-custom-block-note-border);
}
.vp-doc .hint-container.note a,
.vp-doc .hint-container.note code {
color: var(--vp-c-brand-1);
}
.vp-doc .hint-container.note a:hover,
.vp-doc .hint-container.note a:hover > code {
color: var(--vp-c-brand-2);
}
.vp-doc .hint-container.note code {
background-color: var(--vp-custom-block-note-code-bg);
}
.vp-doc .hint-container.tip {
color: var(--vp-custom-block-tip-text);
background-color: var(--vp-custom-block-tip-bg);
border-color: var(--vp-custom-block-tip-border);
}
.vp-doc .hint-container.tip a,
.vp-doc .hint-container.tip code {
color: var(--vp-c-tip-1);
}
.vp-doc .hint-container.tip a:hover,
.vp-doc .hint-container.tip a:hover > code {
color: var(--vp-c-tip-2);
}
.vp-doc .hint-container.tip code {
background-color: var(--vp-custom-block-tip-code-bg);
}
.vp-doc .hint-container.important {
color: var(--vp-custom-block-important-text);
background-color: var(--vp-custom-block-important-bg);
border-color: var(--vp-custom-block-important-border);
}
.vp-doc .hint-container.important a,
.vp-doc .hint-container.important code {
color: var(--vp-c-important-1);
}
.vp-doc .hint-container.important a:hover,
.vp-doc .hint-container.important a:hover > code {
color: var(--vp-c-important-2);
}
.vp-doc .hint-container.important code {
background-color: var(--vp-custom-block-important-code-bg);
}
.vp-doc .hint-container.warning {
color: var(--vp-custom-block-warning-text);
background-color: var(--vp-custom-block-warning-bg);
border-color: var(--vp-custom-block-warning-border);
}
.vp-doc .hint-container.warning a,
.vp-doc .hint-container.warning code {
color: var(--vp-c-warning-1);
}
.vp-doc .hint-container.warning a:hover,
.vp-doc .hint-container.warning a:hover > code {
color: var(--vp-c-warning-2);
}
.vp-doc .hint-container.warning code {
background-color: var(--vp-custom-block-warning-code-bg);
}
.vp-doc .hint-container.danger {
color: var(--vp-custom-block-danger-text);
background-color: var(--vp-custom-block-danger-bg);
border-color: var(--vp-custom-block-danger-border);
}
.vp-doc .hint-container.danger a,
.vp-doc .hint-container.danger code {
color: var(--vp-c-danger-1);
}
.vp-doc .hint-container.danger a:hover,
.vp-doc .hint-container.danger a:hover > code {
color: var(--vp-c-danger-2);
}
.vp-doc .hint-container.danger code {
background-color: var(--vp-custom-block-danger-code-bg);
}
.vp-doc .hint-container.caution {
color: var(--vp-custom-block-caution-text);
background-color: var(--vp-custom-block-caution-bg);
border-color: var(--vp-custom-block-caution-border);
}
.vp-doc .hint-container.caution a,
.vp-doc .hint-container.caution code {
color: var(--vp-c-caution-1);
}
.vp-doc .hint-container.caution a:hover,
.vp-doc .hint-container.caution a:hover > code {
color: var(--vp-c-caution-2);
}
.vp-doc .hint-container.caution code {
background-color: var(--vp-custom-block-caution-code-bg);
}
.vp-doc .hint-container.details {
color: var(--vp-custom-block-details-text);
background-color: var(--vp-custom-block-details-bg);
border-color: var(--vp-custom-block-details-border);
}
.vp-doc .hint-container.details a {
color: var(--vp-c-brand-1);
}
.vp-doc .hint-container.details a:hover,
.vp-doc .hint-container.details a:hover > code {
color: var(--vp-c-brand-2);
}
.vp-doc .hint-container.details code {
background-color: var(--vp-custom-block-details-code-bg);
.vp-doc .hint-container-title::before {
display: inline-block;
width: 1.25em;
height: 1.25em;
margin-right: 4px;
vertical-align: middle;
content: "";
background-image: var(--vp-hint-container-icon);
background-repeat: no-repeat;
background-size: 100%;
transform: translateY(-1px);
}
.vp-doc .hint-container-title {
font-weight: 600;
}
.vp-doc .hint-container p {
line-height: var(--vp-custom-block-line-height);
}
.vp-doc .hint-container th,
.vp-doc .hint-container blockquote > p {
font-size: var(--vp-custom-block-font-size);
color: inherit;
}
.vp-doc .hint-container th,
.vp-doc .hint-container blockquote > p {
font-size: var(--vp-custom-block-font-size);
color: inherit;
}
.vp-doc .hint-container p + p {
margin: 8px 0;
}
.vp-doc .hint-container p + p {
margin: 8px 0;
}
@ -191,26 +95,6 @@
opacity: 0.75;
}
.vp-doc .hint-container code {
font-size: var(--vp-custom-block-code-font-size);
}
.vp-doc .hint-container.vp-doc .hint-container th,
.vp-doc .hint-container.vp-doc .hint-container blockquote > p {
font-size: var(--vp-custom-block-font-size);
color: inherit;
}
/* ---------------------------------------- */
.vp-doc .hint-container p {
line-height: var(--vp-custom-block-line-height);
}
.vp-doc .hint-container p + p {
margin: 8px 0;
}
.vp-doc .hint-container > :not(summary):first-child {
margin-top: 0 !important;
}
@ -219,12 +103,6 @@
margin-bottom: 8px !important;
}
.vp-doc .hint-container th,
.vp-doc .hint-container blockquote > p {
font-size: var(--vp-custom-block-font-size);
color: inherit;
}
.vp-doc .hint-container div[class*="language-"] {
margin: 16px 0;
}
@ -262,49 +140,134 @@
}
}
.vp-doc .hint-container-title::before {
display: inline-block;
width: 1.25em;
height: 1.25em;
margin-right: 4px;
vertical-align: middle;
content: "";
background-image: var(--icon);
background-repeat: no-repeat;
background-size: 100%;
transform: translateY(-1px);
}
@media print {
.vp-doc .hint-container-title::before {
display: none;
}
}
.vp-doc .hint-container.note .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E");
.vp-doc .hint-container:where(.info,.todo) {
--vp-hint-container-text: var(--vp-custom-block-info-text);
--vp-hint-container-bg: var(--vp-custom-block-info-bg);
--vp-hint-container-border: var(--vp-custom-block-info-border);
--vp-hint-container-code-bg: var(--vp-custom-block-info-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E");
}
.vp-doc .hint-container.info .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E");
.vp-doc .hint-container:where(.note,.quote,.cite) {
--vp-hint-container-text: var(--vp-custom-block-note-text);
--vp-hint-container-bg: var(--vp-custom-block-note-bg);
--vp-hint-container-border: var(--vp-custom-block-note-border);
--vp-hint-container-link-text: var(--vp-c-brand-1);
--vp-hint-container-link-hover: var(--vp-c-brand-2);
--vp-hint-container-code-text: var(--vp-c-brand-1);
--vp-hint-container-code-bg: var(--vp-custom-block-note-code-bg);
}
.vp-doc .hint-container.tip .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E");
.vp-doc .hint-container:where(.tip,.hint) {
--vp-hint-container-text: var(--vp-custom-block-tip-text);
--vp-hint-container-bg: var(--vp-custom-block-tip-bg);
--vp-hint-container-border: var(--vp-custom-block-tip-border);
--vp-hint-container-link-text: var(--vp-c-tip-1);
--vp-hint-container-link-hover: var(--vp-c-tip-2);
--vp-hint-container-code-text: var(--vp-c-tip-1);
--vp-hint-container-code-bg: var(--vp-custom-block-tip-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E");
}
.vp-doc .hint-container.warning .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E");
.vp-doc .hint-container:where(.important,.example) {
--vp-hint-container-text: var(--vp-custom-block-important-text);
--vp-hint-container-bg: var(--vp-custom-block-important-bg);
--vp-hint-container-border: var(--vp-custom-block-important-border);
--vp-hint-container-link-text: var(--vp-c-important-1);
--vp-hint-container-link-hover: var(--vp-c-important-2);
--vp-hint-container-code-text: var(--vp-c-important-1);
--vp-hint-container-code-bg: var(--vp-custom-block-important-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E");
}
.vp-doc .hint-container.danger .hint-container-title::before,
.vp-doc .hint-container.caution .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E");
width: 1.4em;
height: 1.4em;
.vp-doc .hint-container:where(.success,.check,.done) {
--vp-hint-container-text: var(--vp-custom-block-success-text);
--vp-hint-container-bg: var(--vp-custom-block-success-bg);
--vp-hint-container-border: var(--vp-custom-block-success-border);
--vp-hint-container-link-text: var(--vp-c-success-1);
--vp-hint-container-link-hover: var(--vp-c-success-2);
--vp-hint-container-code-text: var(--vp-c-success-1);
--vp-hint-container-code-bg: var(--vp-custom-block-success-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%2318794e' d='M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z'/%3E%3C/svg%3E");
}
.vp-doc .hint-container.important .hint-container-title::before {
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E");
.vp-doc .hint-container:where(.warning,.question,.help,.faq) {
--vp-hint-container-text: var(--vp-custom-block-warning-text);
--vp-hint-container-bg: var(--vp-custom-block-warning-bg);
--vp-hint-container-border: var(--vp-custom-block-warning-border);
--vp-hint-container-link-text: var(--vp-c-warning-1);
--vp-hint-container-link-hover: var(--vp-c-warning-2);
--vp-hint-container-code-text: var(--vp-c-warning-1);
--vp-hint-container-code-bg: var(--vp-custom-block-warning-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.danger,.caution,.attention,.failure,.fail,.missing,.bug,.error) {
--vp-hint-container-text: var(--vp-custom-block-danger-text);
--vp-hint-container-bg: var(--vp-custom-block-danger-bg);
--vp-hint-container-border: var(--vp-custom-block-danger-border);
--vp-hint-container-link-text: var(--vp-c-danger-1);
--vp-hint-container-link-hover: var(--vp-c-danger-2);
--vp-hint-container-code-text: var(--vp-c-danger-1);
--vp-hint-container-code-bg: var(--vp-custom-block-danger-code-bg);
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.caution) {
--vp-hint-container-text: var(--vp-custom-block-caution-text);
--vp-hint-container-bg: var(--vp-custom-block-caution-bg);
--vp-hint-container-border: var(--vp-custom-block-caution-border);
--vp-hint-container-link-text: var(--vp-c-caution-1);
--vp-hint-container-link-hover: var(--vp-c-caution-2);
--vp-hint-container-code-text: var(--vp-c-caution-1);
--vp-hint-container-code-bg: var(--vp-custom-block-caution-code-bg);
}
.vp-doc .hint-container:where(.details,.abstract,.summary,.tldr) {
--vp-hint-container-text: var(--vp-custom-block-details-text);
--vp-hint-container-bg: var(--vp-custom-block-details-bg);
--vp-hint-container-border: var(--vp-custom-block-details-border);
--vp-hint-container-code-bg: var(--vp-custom-block-details-code-bg);
}
.vp-doc .hint-container:where(.note) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%235da1a2' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10 4H7.2c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C4 5.52 4 6.08 4 7.2v9.6c0 1.12 0 1.68.218 2.108a2 2 0 0 0 .874.874c.427.218.987.218 2.105.218h9.606c1.118 0 1.677 0 2.104-.218c.377-.192.683-.498.875-.874c.218-.428.218-.987.218-2.105V14m-4-9l-6 6v3h3l6-6m-3-3l3-3l3 3l-3 3m-3-3l3 3'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.quote,.cite) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.hint) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23299764' stroke-linejoin='round' stroke-width='2' d='M15 19c1.2-3.678 2.526-5.005 6-6c-3.474-.995-4.8-2.322-6-6c-1.2 3.678-2.526 5.005-6 6c3.474.995 4.8 2.322 6 6Zm-8-9c.6-1.84 1.263-2.503 3-3c-1.737-.497-2.4-1.16-3-3c-.6 1.84-1.263 2.503-3 3c1.737.497 2.4 1.16 3 3Zm1.5 10c.3-.92.631-1.251 1.5-1.5c-.869-.249-1.2-.58-1.5-1.5c-.3.92-.631 1.251-1.5 1.5c.869.249 1.2.58 1.5 1.5Z'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.todo) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 2048 2048'%3E%3Cpath fill='%232196f3' d='M768 256h1280v128H768zm0 768V896h1280v128zm0 640v-128h1280v128zM256 768q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12m0 240q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12M192 358L467 83l90 90l-365 365L19 365l90-90z'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.question,.help) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cg fill='none'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23946300' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 14a1 1 0 1 0 0 2a1 1 0 0 0 0-2m0-9.5a3.625 3.625 0 0 0-3.625 3.625a1 1 0 1 0 2 0a1.625 1.625 0 1 1 2.23 1.51c-.676.27-1.605.962-1.605 2.115V14a1 1 0 1 0 2 0c0-.244.05-.366.261-.47l.087-.04A3.626 3.626 0 0 0 12 6.5'/%3E%3C/g%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.faq) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23946300' d='M18.05 6.56a.84.84 0 0 0-.84.84v4.78a.84.84 0 1 0 1.68 0V7.4a.85.85 0 0 0-.84-.84m-6.37 0a.85.85 0 0 0-.84.84v2.43h1.68V7.4a.85.85 0 0 0-.84-.84'/%3E%3Cpath fill='%23946300' d='M22.88 1.94H1.12A1.12 1.12 0 0 0 0 3.06v14.5a1.12 1.12 0 0 0 1.12 1.13h2.6a8.3 8.3 0 0 1-1.48 3.37c3.26.05 5.32-1.21 6.55-3.37h14.09A1.12 1.12 0 0 0 24 17.56V3.06a1.12 1.12 0 0 0-1.12-1.12M7.54 6.56H6a.85.85 0 0 0-.84.84v2.43h1.59a.75.75 0 0 1 0 1.5H5.11v2.44a.75.75 0 1 1-1.5 0V7.4A2.34 2.34 0 0 1 6 5.06h1.54a.75.75 0 1 1 0 1.5M14 13.77a.75.75 0 0 1-1.5 0v-2.44h-1.66v2.44a.75.75 0 0 1-1.5 0V7.4a2.34 2.34 0 1 1 4.66 0Zm6.37-1.59a2.37 2.37 0 0 1-1 1.9l.75.75a.75.75 0 0 1 0 1.06a.75.75 0 0 1-1.06 0l-1.4-1.4a2.34 2.34 0 0 1-2-2.31V7.4a2.34 2.34 0 1 1 4.68 0Z'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.example) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Ccircle cx='4' cy='7' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='12' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='17' r='1' fill='%237e4cc9'/%3E%3Crect width='14' height='2' x='7' y='11' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='16' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='6' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.attention) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 48 48'%3E%3Cdefs%3E%3Cmask id='SVGkYaseeMx'%3E%3Cg fill='none'%3E%3Cpath fill='%23fff' stroke='%23fff' stroke-linejoin='round' stroke-width='4' d='M24 44a19.94 19.94 0 0 0 14.142-5.858A19.94 19.94 0 0 0 44 24a19.94 19.94 0 0 0-5.858-14.142A19.94 19.94 0 0 0 24 4A19.94 19.94 0 0 0 9.858 9.858A19.94 19.94 0 0 0 4 24a19.94 19.94 0 0 0 5.858 14.142A19.94 19.94 0 0 0 24 44Z'/%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M24 37a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' d='M24 12v16'/%3E%3C/g%3E%3C/mask%3E%3C/defs%3E%3Cpath fill='%23d5393e' d='M0 0h48v48H0z' mask='url(%23SVGkYaseeMx)'/%3E%3C/svg%3E");
}
.vp-doc .hint-container:where(.bug) {
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23d5393e' d='m21.55 9.11l-1.84.92v-.02H4.29v.02l-1.84-.92l-.89 1.79l2.47 1.23c0 .12-.02.24-.02.37c0 .51.04 1.01.11 1.5H2.01v2h2.57c.17.5.37.97.6 1.42l-1.87 1.87l1.41 1.41l1.58-1.58c1.23 1.5 2.87 2.52 4.71 2.79v-8.92h2v8.92c1.84-.27 3.48-1.3 4.71-2.79l1.58 1.58l1.41-1.41l-1.87-1.87c.23-.45.43-.93.6-1.42h2.57v-2H19.9c.07-.49.11-.99.11-1.5c0-.13-.02-.25-.02-.37l2.47-1.23l-.89-1.79ZM4.96 8h14.09c-.37-.82-.86-1.56-1.41-2.22l2.07-2.07L18.3 2.3l-2.11 2.11C14.97 3.52 13.54 3 12.01 3s-2.96.52-4.18 1.41L5.72 2.3L4.31 3.71l2.07 2.07c-.55.66-1.04 1.4-1.41 2.22Z'/%3E%3C/svg%3E");
}

View File

@ -442,6 +442,11 @@
--vp-custom-block-important-bg: var(--vp-c-important-soft);
--vp-custom-block-important-code-bg: var(--vp-c-important-soft);
--vp-custom-block-success-border: transparent;
--vp-custom-block-success-text: var(--vp-c-text-1);
--vp-custom-block-success-bg: var(--vp-c-success-soft);
--vp-custom-block-success-code-bg: var(--vp-c-success-soft);
--vp-custom-block-warning-border: transparent;
--vp-custom-block-warning-text: var(--vp-c-text-1);
--vp-custom-block-warning-bg: var(--vp-c-warning-soft);

View File

@ -67,6 +67,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
'youtube',
'qrcode',
'encrypt',
'obsidian',
'locales',
]

View File

@ -6,7 +6,7 @@ import type { MarkdownMathPluginOptions } from '@vuepress/plugin-markdown-math'
import type { PluginConfig } from 'vuepress'
import type { MarkdownPowerPluginOptions } from 'vuepress-plugin-md-power'
import type { MarkdownOptions, ThemeBuiltinPlugins } from '../../shared/index.js'
import { isPlainObject } from '@vuepress/helper'
import { isPlainObject } from '@pengzhanbo/utils'
import { markdownChartPlugin } from '@vuepress/plugin-markdown-chart'
import { markdownHintPlugin } from '@vuepress/plugin-markdown-hint'
import { markdownImagePlugin } from '@vuepress/plugin-markdown-image'
@ -25,10 +25,11 @@ export function markdownPlugins(pluginOptions: ThemeBuiltinPlugins): PluginConfi
const options = getThemeConfig()
const plugins: PluginConfig = []
let { hint, image, include, math, mdChart, mdPower } = splitMarkdownOptions(options.markdown ?? {})
const obsidian = isPlainObject(mdPower.obsidian) ? mdPower.obsidian : {}
plugins.push(markdownHintPlugin({
hint: hint.hint ?? true,
alert: hint.alert ?? true,
// 如果启用了 obsidian 兼容,则禁用 hint.alertobsidian callout 已处理 alert
alert: mdPower.obsidian === false ? (hint.alert ?? true) : obsidian.callout === false,
injectStyles: false,
}))

View File

@ -46,12 +46,11 @@ export function resolveMatcherPattern(include?: string | string[], exclude?: str
*
* @param include - Patterns to include /
* @param exclude - Patterns to exclude /
* @param cwd - Current working directory for relative path matching /
* @returns Matcher function that tests file paths /
*/
export function createMatcher(include?: string | string[], exclude?: string | string[], cwd?: string): Matcher {
export function createMatcher(include?: string | string[], exclude?: string | string[]): Matcher {
exclude = ['**/node_modules/**', '**/.vuepress/**', ...toArray(exclude)]
const { pattern, ignore } = resolveMatcherPattern(include, exclude)
return picomatch(pattern, { ignore, cwd })
return picomatch(pattern, { ignore })
}

View File

@ -2,7 +2,6 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
"@theme/*": ["./theme/src/client/components/*"]