Compare commits

..

No commits in common. "main" and "v1.0.0-rc.195" have entirely different histories.

33 changed files with 1020 additions and 1472 deletions

View File

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

View File

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

View File

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

View File

@ -5,10 +5,6 @@ on:
tags:
- v*
permissions:
contents: write
id-token: write
jobs:
lint:
uses: ./.github/workflows/lint.yaml
@ -20,6 +16,9 @@ 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,9 +8,6 @@ on:
branches: [main]
workflow_call:
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest

View File

@ -1,12 +1,3 @@
# [1.0.0-rc.196](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.195...v1.0.0-rc.196) (2026-04-19)
### Bug Fixes
* fix security ([e512666](https://github.com/pengzhanbo/vuepress-theme-plume/commit/e5126663ef50b4f720424412b38ad3b22a7178da))
* **plugin-md-power:** fix cell display issue caused by colspan in table ([#891](https://github.com/pengzhanbo/vuepress-theme-plume/issues/891)) ([6ebb1bd](https://github.com/pengzhanbo/vuepress-theme-plume/commit/6ebb1bda6e4ef379e156e7299c149e10c472d705))
* **theme:** remove `cwd` options from `picomatch` ([#892](https://github.com/pengzhanbo/vuepress-theme-plume/issues/892)) ([58ea2fc](https://github.com/pengzhanbo/vuepress-theme-plume/commit/58ea2fc8cbcef0cb968997ad6dead2f4960d465a))
# [1.0.0-rc.195](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.194...v1.0.0-rc.195) (2026-04-18)

View File

@ -1,7 +1,7 @@
{
"name": "create-vuepress-theme-plume",
"type": "module",
"version": "1.0.0-rc.196",
"version": "1.0.0-rc.195",
"description": "The cli for create vuepress-theme-plume's project",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
"license": "MIT",

View File

@ -7,7 +7,8 @@ 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.
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:
@ -20,7 +21,7 @@ Currently supported Obsidian extension syntax includes:
## Wiki Links
Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links.
Wiki Links are syntax for linking to other notes in Obsidian.
### Syntax
@ -30,7 +31,6 @@ Wiki Links are syntax used in Obsidian for linking to other notes. Use double br
[[filename#heading#subheading]]
[[filename|alias]]
[[filename#heading|alias]]
[[https://example.com|External Link]]
```
### Filename Search Rules
@ -39,14 +39,15 @@ 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
1. **Page Title** - Priority matching against page titles
2. **Full Path** - Exact match against file paths
3. **Fuzzy Match** - Match filenames at the end of paths
**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
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, with shortest path taking precedence
- **Directory form** (ending with `/`): Matches `README.md` or `index.html` within that directory
**Example:**
@ -54,21 +55,22 @@ Assuming the following document structure:
```txt
docs/
├── README.md
├── README.md (title: "Home")
├── guide/
│ ├── README.md
│ ├── README.md (title: "Guide")
│ └── 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) |
| Syntax | Match Result |
| ------------ | ------------------------------------------------------- |
| `[[Home]]` | Matches `docs/README.md` (via title) |
| `[[Guide]]` | Matches `docs/guide/README.md` (via title) |
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
### Examples
@ -84,26 +86,31 @@ In `docs/guide/markdown/obsidian.md`:
[[https://example.com|External Link]]
---
**Internal Anchor Links:**
**Input:**
```md
[[QR Code]] <!-- Search by title -->
[[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 -->
[[file-tree#configuration]] <!-- Search by filename, link to heading -->
```
**Output:**
[[QR Code]]
[[npm-to]]
[[guide/markdown/math]]
[[#Wiki Links]]
[[file-tree#Configuration]]
[[file-tree#configuration]]
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
@ -129,38 +136,22 @@ Filename search rules are the same as [Wiki Links](#filename-search-rules).
**Syntax:**
```md
![[image]]
![[image|300]]
![[image|300x200]]
![[image.png]]
![[image.png|300]]
![[image.png|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
**Input:**
```md
![[images/custom-hero.jpg]]
```
:::
**Output:**
::: 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]]
```
:::
![[images/custom-hero.jpg]]
### PDF Embeds
@ -171,38 +162,46 @@ Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `i
```md
![[document.pdf]]
![[document.pdf#page=1]] <!-- #page=1 means first page -->
![[document.pdf#page=1#height=300]] <!-- #page=page number #height=height -->
![[document.pdf#page=1]] <!-- #page=1 means first page -->
![[document.pdf#page=1#height=300]] <!-- #height=300 means height of 300px -->
```
Supported formats: `pdf`
---
### Audio Embeds
**Syntax:**
> [!note]
> Audio embeds require the file path to be correct and the file to exist in the document directory.
**Input:**
```md
![[audio file]]
![[audio.mp3]]
```
**Output:**
![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]]
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
---
### Video Embeds
> [!NOTE]
> [!note]
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
**Syntax:**
**Input:**
```md
![[video file]]
![[video file#height=400]] <!-- Set video height -->
![[video.mp4]]
```
**Output:**
![[https://artplayer.org/assets/sample/video.mp4]]
Supported formats: `mp4`, `webm`, `mov`, etc.
---
@ -215,12 +214,12 @@ Content fragments under a specified heading can be embedded using `#heading`:
```md
![[my-note]]
![[my-note#Heading One]]
![[my-note#Heading One#Subheading]]
![[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}
[Obsidian Official - Embeds](https://obsidian.md/en/help/embeds){.readmore}
[Obsidian Official - File Formats](https://obsidian.md/en/help/file-formats){.readmore}
## Comments
@ -281,25 +280,28 @@ 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}
Content after the comment
> Related Documentation: [Obsidian Official - Comments](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B)
## Configuration
Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration:
You can enable or disable these plugins in the theme configuration:
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
plugins: {
mdPower: {
// Obsidian compatibility plugin configuration
obsidian: {
wikiLink: true, // Wiki Links
embedLink: true, // Embeds
comment: true, // Comments
embedLink: true, // Embeds
comment: true, // Comments
},
pdf: true, // PDF embed functionality
artPlayer: true, // Video embed functionality
@ -314,15 +316,15 @@ export default defineUserConfig({
:::: field-group
::: field name="wikiLink" type="boolean" default="true" optional
Enable Wiki Links syntax.
Enable Wiki Links syntax
:::
::: field name="embedLink" type="boolean" default="true" optional
Enable embed content syntax.
Enable embed content syntax
:::
::: field name="comment" type="boolean" default="true" optional
Enable comment syntax.
Enable comment syntax
:::
::::
@ -330,8 +332,7 @@ Enable comment syntax.
## 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
- Some Obsidian-specific features (such as the graph view for internal links, 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
- PDF embeds require the `pdf` plugin to be enabled simultaneously
- Video embeds require the `artPlayer` plugin to be enabled simultaneously

View File

@ -15,12 +15,12 @@ permalink: /guide/markdown/obsidian/
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
- [注释](#注释) - 添加仅在编辑时可见的注释
::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法
::: warning 不计划对 obsidian 社区的第三方插件提供的扩展语法进行支持
:::
## Wiki 链接
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
### 语法
@ -30,7 +30,6 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括
[[文件名#标题#子标题]]
[[文件名|别名]]
[[文件名#标题|别名]]
[[https://example.com|外部链接]]
```
### 文件名搜索规则
@ -39,14 +38,15 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括
**匹配优先级:**
1. **完整路径** - 精确匹配文件路径
2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径
1. **页面标题** - 优先匹配页面的标题
2. **完整路径** - 精确匹配文件路径
3. **模糊匹配** - 匹配路径结尾的文件名
**路径解析规则:**
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md`
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md``index.html`
**示例:**
@ -54,21 +54,22 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括
```txt
docs/
├── README.md
├── README.md (title: "首页")
├── guide/
│ ├── README.md
│ ├── README.md (title: "指南")
│ └── 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`(目录形式) |
| 语法 | 匹配结果 |
| ------------ | ------------------------------------------------ |
| `[[首页]]` | 匹配 `docs/README.md`(通过标题) |
| `[[指南]]` | 匹配 `docs/guide/README.md`(通过标题) |
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
### 示例
@ -89,6 +90,7 @@ docs/
**输入:**
```md
[[二维码]] <!-- 通过标题检索 -->
[[npm-to]] <!-- 通过文件名检索 -->
[[guide/markdown/math]] <!-- 通过文件路径检索-->
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
@ -97,6 +99,8 @@ docs/
**输出:**
[[二维码]]
[[npm-to]]
[[guide/markdown/math]]
@ -129,38 +133,22 @@ docs/
**语法:**
```md
![[图片]]
![[图片|宽度]]
![[图片|宽度x高度]]
![[image.png]]
![[image.png|300]]
![[image.png|300x200]]
```
支持格式:`jpg``jpeg``png``gif``avif``webp``svg``bmp``ico``tiff``apng``jfif``pjpeg``pjp``xbm`
支持格式:`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]]
```
:::
![[images/custom-hero.jpg]]
### PDF 嵌入
@ -170,40 +158,48 @@ docs/
**语法:**
```md
![[文档.pdf]]
![[文档.pdf#page=1]] <!-- #page=1 表示第一页 -->
![[文档.pdf#page=1#height=300]] <!-- #page=页码 #height=高度 -->
![[document.pdf]]
![[document.pdf#page=1]] <!-- #page=1 表示第一页 -->
![[document.pdf#page=1#height=300]] <!-- #height=300 表示高度为 300px -->
```
支持格式:`pdf`
---
### 音频嵌入
**语法:**
> [!note]
> 音频嵌入需要确保文件路径正确,文件存在于文档目录中。
**输入:**
```md
![[音频文件]]
![[audio.mp3]]
```
支持格式:`mp3``flac``wav``ogg``opus``webm``acc`
**输出:**
![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]]
支持格式:`mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
---
### 视频嵌入
> [!NOTE]
> [!note]
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
**语法**
**输入**
```md
![[视频文件]]
![[视频文件#height=400]] <!-- 设置视频高度 -->
![[video.mp4]]
```
支持格式:`mp4``webm``mov`
**输出:**
![[https://artplayer.org/assets/sample/video.mp4]]
支持格式:`mp4`, `webm`, `mov`
---
@ -281,25 +277,28 @@ docs/
%%
这是一个块级注释。
%%
可以跨越多行。
%%
注释之后的内容
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
## 配置
Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用
你可以在主题配置中启用或禁用这些插件
```ts title=".vuepress/config.ts"
export default defineUserConfig({
theme: plumeTheme({
plugins: {
mdPower: {
// Obsidian 兼容插件配置
obsidian: {
wikiLink: true, // Wiki 链接
embedLink: true, // 嵌入内容
comment: true, // 注释
embedLink: true, // 嵌入内容
comment: true, // 注释
},
pdf: true, // PDF 嵌入功能
artPlayer: true, // 视频嵌入功能
@ -314,15 +313,15 @@ export default defineUserConfig({
:::: field-group
::: field name="wikiLink" type="boolean" default="true" optional
启用 Wiki 链接语法
启用 Wiki 链接语法
:::
::: field name="embedLink" type="boolean" default="true" optional
启用嵌入内容语法
启用嵌入内容语法
:::
::: field name="comment" type="boolean" default="true" optional
启用注释语法
启用注释语法
:::
::::
@ -332,6 +331,5 @@ export default defineUserConfig({
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
- PDF 嵌入需要同时启用 `markdown.pdf` 插件
- 视频嵌入需要同时启用 `markdown.artPlayer` 插件
- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载
- PDF 嵌入需要同时启用 `pdf` 插件
- 视频嵌入需要同时启用 `artPlayer` 插件

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "vuepress-theme-plume-monorepo",
"type": "module",
"version": "1.0.0-rc.196",
"version": "1.0.0-rc.195",
"private": true,
"packageManager": "pnpm@10.33.0",
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
@ -92,12 +92,10 @@
"@shikijs/twoslash": "^4.0.2",
"@typescript-eslint/types": "catalog:peer",
"@typescript-eslint/utils": "catalog:peer",
"@xmldom/xmldom": ">=0.9.10",
"baseline-browser-mapping": "^2.10.20",
"@xmldom/xmldom": ">=0.9.9",
"baseline-browser-mapping": "^2.10.19",
"chokidar": "catalog:prod",
"dompurify": ">=3.4.0",
"esbuild": "catalog:prod",
"follow-redirects": ">=1.16.0",
"lodash": ">=4.18.1",
"lodash-es": ">=4.18.1",
"sass-embedded": "catalog:peer",

View File

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

View File

@ -1,421 +0,0 @@
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(/^\//, '')),
}))
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()
})
})
// ==================== 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()
})
})
// ==================== 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('![[]]')
})
})
})

View File

@ -0,0 +1,446 @@
import type { App } from 'vuepress'
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { describe, expect, it, vi } from 'vitest'
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
function createMockApp(pages: App['pages'] = []): App {
return {
pages,
} 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 = () => ''
})
}
vi.mock('gray-matter', () => ({
default: vi.fn(content => ({
content: content.replace(/^---[\s\S]*?---\n?/, ''),
data: {},
})),
}))
describe('embedLinkPlugin - internal markdown embed', () => {
it('should embed entire markdown file when no heading specified', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction
This is the guide content.
## Getting Started
Step 1, Step 2, Step 3.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide]]', createMockEnv('test.md'))
expect(result).toContain('<h1')
expect(result).toContain('Introduction')
expect(result).toContain('<h2')
expect(result).toContain('Getting Started')
})
it('should embed content under specific heading', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction
This is intro.
## Getting Started
Steps for getting started.
## Advanced
Advanced content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md'))
expect(result).toContain('Steps for getting started')
expect(result).not.toContain('Advanced content')
})
it('should embed nested heading content', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction
## Installation
### Prerequisites
Software requirements.
### Download
Download links.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Installation#Download]]', createMockEnv('test.md'))
expect(result).toContain('Download links')
expect(result).not.toContain('Prerequisites')
})
it('should handle heading with id syntax', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction {#intro}
Intro content.
## Getting Started {#getting-started}
Start content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md'))
expect(result).toContain('Start content')
})
it('should preserve container blocks within embedded content', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction
::: warning
This is a warning block.
:::
Some text.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide]]', createMockEnv('test.md'))
expect(result).toContain('::: warning')
expect(result).toContain('This is a warning block')
})
it('should return empty string when heading not found', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction
Content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#NonExistent]]', createMockEnv('test.md'))
expect(result.trim()).toBe('')
})
it('should add importedFiles to env when embedding', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `# Guide\n\nContent.`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const env = createMockEnv('test.md')
md.render('![[Guide]]', env)
expect(env.importedFiles).toContain('docs/guide.md')
})
it('should handle heading with special characters', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Guide () {#zh}
Chinese content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Guide (中文)]]', createMockEnv('test.md'))
expect(result).toContain('Chinese content')
})
it('should handle heading with class syntax', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Introduction {.intro .basic}
Intro content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Introduction]]', createMockEnv('test.md'))
expect(result).toContain('Intro content')
})
it('should handle multiple consecutive container blocks', async () => {
const mockPage = {
path: '/docs/guide/',
filePathRelative: 'docs/guide.md',
title: 'Guide',
content: `---
title: Guide
---
# Section
::: info
Info block.
:::
::: warning
Warning block.
:::
More text.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Guide#Section]]', createMockEnv('test.md'))
expect(result).toContain('Info block')
expect(result).toContain('Warning block')
expect(result).toContain('More text')
})
it('should handle frontmatter in embedded file', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `---
title: Actual Title
author: Test Author
---
# Actual Title
Page content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Page]]', createMockEnv('test.md'))
expect(result).toContain('Page content')
expect(result).not.toContain('author')
})
})
describe('extractContentByHeadings', () => {
it('should return full content when no headings specified', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `# Title\n\nContent here.`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Page]]', createMockEnv('test.md'))
expect(result).toContain('Content here')
})
it('should restart heading search when encountering same text at lower level', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `---
---
# Section
## Subsection
Subsection content.
# Section
## Another
Another content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Page#Section#Another]]', createMockEnv('test.md'))
expect(result).toContain('Another content')
expect(result).not.toContain('Subsection content')
})
it('should reset search when encountering different heading at lower level', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `---
---
# Section
## Subsection
Subsection content.
# Other
## Content
Other content.
`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
// Search for "Section#Content" - after matching "Section" and failing to find "Content"
// under "Subsection" (which is level 2 > 1), we encounter "Other" at level 1
// heading.level (1) <= currentLevel (2), and "Other" !== "Section"
// So we enter the else branch at lines 268-270: headingPointer = 0, currentLevel = 0
const result = md.render('![[Page#Section#Content]]', createMockEnv('test.md'))
expect(result.trim()).toBe('')
})
it('should extract content between sibling headings', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `# Title\n\nIntro.\n\n## Section1\n\nSection 1 content.\n\n## Section2\n\nSection 2 content.\n`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Page#Section1]]', createMockEnv('test.md'))
expect(result).toContain('Section 1 content')
expect(result).not.toContain('Section 2 content')
})
it('should handle deep nested headings', async () => {
const mockPage = {
path: '/docs/page/',
filePathRelative: 'docs/page.md',
title: 'Page',
content: `# H1\n\n## H2a\n\n### H3a\n\nH3a content.\n\n### H3b\n\nH3b content.\n\n## H2b\n\nH2b content.\n`,
markdownEnv: { base: '/' },
}
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
const result = md.render('![[Page#H2a#H3b]]', createMockEnv('test.md'))
expect(result).toContain('H3b content')
expect(result).not.toContain('H3a content')
})
})

View File

@ -0,0 +1,124 @@
import type { App } from 'vuepress'
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
function createMockApp(pages: App['pages'] = []): App {
return {
pages,
} 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', () => {
const mockApp = createMockApp()
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
it('should render image embed', () => {
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 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 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 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 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 result = md.render('![[document.pdf]]')
expect(result).toContain('<PDFViewer')
expect(result).toContain('src="/document.pdf"')
})
it('should render pdf with page hash', () => {
const result = md.render('![[document.pdf#page=1]]')
expect(result).toContain('page="1"')
})
it('should render external http link as anchor', () => {
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 render relative path with dot prefix', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('![[./image.png]]', env)
expect(result).toContain('<img')
})
it('should render absolute path with slash prefix', () => {
const result = md.render('![[/images/cover.jpg]]')
expect(result).toContain('<img')
expect(result).toContain('src="/images/cover.jpg"')
})
it('should ignore non-image with unsupported extension as link', () => {
const result = md.render('![[file.unknown]]')
expect(result).toContain('<a')
expect(result).toContain('href="file.unknown"')
})
it('should not parse embed not ending with ]]', () => {
const result = md.render('![[image.png]')
expect(result).toContain('![[image.png]')
})
it('should render markdown file embed as link when page not found', () => {
const result = md.render('![[nonexistent.md]]')
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent.md"')
})
it('should render markdown file embed as link when page and headings not found', () => {
const result = md.render('![[nonexistent.md#heading1#heading2]]')
expect(result).toContain('<a')
expect(result).toContain('href="/nonexistent.md#heading2"')
})
it('should not parse empty embed link', () => {
const result = md.render('![[]]')
expect(result).toContain('![[]]')
})
})

View File

@ -1,297 +0,0 @@
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

@ -1,156 +0,0 @@
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

@ -1,28 +1,12 @@
import type { App } from 'vuepress'
import MarkdownIt from 'markdown-it'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } 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
} as App
}
function createMarkdownWithMockRules() {
@ -36,11 +20,13 @@ 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, {})
obsidianPlugin(md, mockApp, {})
const embedResult = md.render('![[image.png]]')
expect(embedResult).toContain('<img')
// Wiki link should not work since findFirstPage returns undefined when pagePaths is empty
const wikiResult = md.render('[[Home]]')
expect(wikiResult).not.toContain('<VPLink')
expect(wikiResult).toContain('<VPLink')
const commentResult = md.render('%%comment%%')
expect(commentResult).not.toContain('comment')
@ -49,7 +35,7 @@ describe('obsidianPlugin', () => {
it('should allow disabling specific plugins', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: { wikiLink: false } })
obsidianPlugin(md, mockApp, { obsidian: { wikiLink: false } })
const wikiResult = md.render('[[Page]]')
expect(wikiResult).not.toContain('<VPLink')
@ -59,16 +45,17 @@ describe('obsidianPlugin', () => {
it('should disable all plugins when obsidian is false', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: false })
obsidianPlugin(md, mockApp, { obsidian: false })
const result = md.render('![[image.png]]')
expect(result).not.toContain('<img')
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 } })
obsidianPlugin(md, mockApp, { obsidian: { embedLink: false } })
const result = md.render('![[image.png]]')
expect(result).not.toContain('<img')
@ -77,7 +64,10 @@ describe('obsidianPlugin', () => {
it('should disable comment when explicitly set to false', () => {
const md = createMarkdownWithMockRules()
const mockApp = createMockApp()
obsidianPlugin(mockApp, md, { obsidian: { comment: false } })
obsidianPlugin(md, mockApp, { obsidian: { comment: false } })
const embedResult = md.render('![[image.png]]')
expect(embedResult).toContain('<img')
const commentResult = md.render('%%comment%%')
expect(commentResult).toContain('%%comment%%')

View File

@ -1,268 +0,0 @@
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

@ -0,0 +1,218 @@
import type { App } from 'vuepress'
import type { MarkdownEnv } from 'vuepress/markdown'
import MarkdownIt from 'markdown-it'
import { describe, expect, it } from 'vitest'
import { findFirstPage, wikiLinkPlugin } from '../src/node/obsidian/wikiLink.js'
function createMockApp(pages: App['pages'] = []): App {
return {
pages,
} as App
}
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
return {
filePathRelative,
base: '/',
links: [],
importedFiles: [],
}
}
describe('wikiLinkPlugin', () => {
const mockApp = createMockApp([
{
path: '/docs/getting-started/',
filePathRelative: 'docs/getting-started/README.md',
title: 'Getting Started',
},
{
path: '/docs/guide/intro/',
filePathRelative: 'docs/guide/intro.md',
title: 'Introduction',
},
{
path: '/api/utils/',
filePathRelative: 'api/utils.md',
title: 'Utils',
},
] as App['pages'])
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin, mockApp)
it('should render internal wiki link to existing page', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[Getting Started]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/docs/getting-started/"')
})
it('should render wiki link with alias', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[Getting Started|Quick Start]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('Quick Start')
})
it('should render wiki link with heading', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[Introduction#Installation]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('href="/docs/guide/intro/#installation"')
})
it('should render wiki link with heading and alias', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[Introduction#Installation|Install Guide]]', env)
expect(result).toContain('<VPLink')
expect(result).toContain('Install Guide')
expect(result).toContain('href="/docs/guide/intro/#installation"')
})
it('should render external http link', () => {
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 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 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 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>')
})
it('should render internal hash link for empty filename', () => {
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 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 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>')
})
it('should render relative path wiki link as anchor when not found', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[../api/other.md]]', env)
expect(result).toContain('<a')
expect(result).toContain('href="/api/other.md"')
})
it('should render relative path wiki link with alias', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[../api/other.md|View API]]', env)
expect(result).toContain('>View API<')
expect(result).toContain('href="/api/other.md"')
})
it('should render relative path wiki link with heading but no alias', () => {
const env = createMockEnv('docs/page.md')
const result = md.render('[[../api/other.md#section]]', env)
expect(result).toContain('href="/api/other.md#section"')
expect(result).toContain('../api/other.md &gt; section</a>')
})
it('should add to links array in env', () => {
const env = createMockEnv('docs/page.md')
md.render('[[Utils]]', env)
expect(env.links).toBeDefined()
expect(env.links!.length).toBeGreaterThan(0)
})
it('should not parse wiki link without closing bracket', () => {
const result = md.render('[[Page')
expect(result).toContain('[[Page')
})
it('should not parse empty wiki link', () => {
const result = md.render('[[]]')
expect(result).toContain('[[]]')
})
})
describe('findFirstPage', () => {
const mockApp = createMockApp([
{ path: '/', filePathRelative: 'README.md', title: 'Home' },
{ path: '/docs/guide/', filePathRelative: 'docs/guide/README.md', title: 'Guide' },
{ path: '/docs/api/', filePathRelative: 'docs/api.md', title: 'API' },
{ path: '/docs/config/', filePathRelative: 'docs/config/index.md', title: 'Config' },
] as App['pages'])
it('should find page by exact title', () => {
const result = findFirstPage(mockApp, 'Guide', 'any/path.md')
expect(result?.title).toBe('Guide')
})
it('should find page by exact file path', () => {
const result = findFirstPage(mockApp, 'docs/api.md', 'any/path.md')
expect(result?.title).toBe('API')
})
it('should find page by exact title with path-like name', () => {
const result = findFirstPage(mockApp, 'docs/config/index', 'any/path.md')
expect(result?.title).toBe('Config')
})
it('should find folder index by trailing slash', () => {
const result = findFirstPage(mockApp, 'docs/guide/', 'any/path.md')
expect(result?.title).toBe('Guide')
})
it('should find page without extension', () => {
const result = findFirstPage(mockApp, 'docs/api', 'any/path.md')
expect(result?.title).toBe('API')
})
it('should find page by fuzzy match with folder path ending in slash', () => {
const app = createMockApp([
{ path: '/docs/features/', filePathRelative: 'docs/features/README.md', title: 'Features' },
] as App['pages'])
const result = findFirstPage(app, 'docs/features/', 'any/path.md')
expect(result?.title).toBe('Features')
})
it('should find page by fuzzy match ending with index.html', () => {
const app = createMockApp([
{ path: '/docs/guide/', filePathRelative: 'docs/guide/index.html', title: 'Guide' },
] as App['pages'])
const result = findFirstPage(app, 'docs/guide/', 'any/path.md')
expect(result?.title).toBe('Guide')
})
it('should return undefined when page not found', () => {
const result = findFirstPage(mockApp, 'nonexistent', 'any/path.md')
expect(result).toBeUndefined()
})
it('should find page by data.title fallback', () => {
const app = createMockApp([
{ path: '/test/', filePathRelative: 'test.md', data: { title: 'Data Title' } },
] as unknown as App['pages'])
const result = findFirstPage(app, 'Data Title', 'any/path.md')
expect(result?.path).toBe('/test/')
})
})

View File

@ -1,7 +1,7 @@
{
"name": "vuepress-plugin-md-power",
"type": "module",
"version": "1.0.0-rc.196",
"version": "1.0.0-rc.195",
"description": "The Plugin for VuePress 2 - markdown power",
"author": "pengzhanbo <volodymyr@foxmail.com>",
"license": "MIT",

View File

@ -88,7 +88,6 @@ 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
@ -101,39 +100,13 @@ 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\b[^>]*>/
const SCRIPT_RE = /<script.*?>/
export function insertSetupScript({ export: name, path }: DemoFile, env: MarkdownDemoEnv): void {
const imports = `import ${name ? `${name} from ` : ''}'${path}';`

View File

@ -17,16 +17,15 @@
import type { RuleBlock } from 'markdown-it/lib/parser_block.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 { hash, path } from 'vuepress/utils'
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
import { parseRect } from '../utils/parseRect.js'
import { slugify } from '../utils/slugify.js'
import { findFirstPage } from './findFirstPage.js'
import { findFirstPage } from './wikiLink.js'
interface EmbedLinkMeta {
filename: string
@ -174,22 +173,13 @@ export function embedLinkPlugin(md: Markdown, app: App): void {
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
const token = tokens[idx]
const { filename, hashes, settings } = token.meta as EmbedLinkMeta
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
const internalPage = findFirstPage(app, filename, env.filePathRelative ?? '')
// 解析为内部 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 ''
}
if (internalPage) {
const { content: rawContent } = grayMatter(internalPage.content)
const content = extractContentByHeadings(rawContent, hashes)
pagePath && (env.importedFiles ??= []).push(pagePath)
return md.render(content, cleanMarkdownEnv(env))
internalPage.filePathRelative && (env.importedFiles ??= []).push(internalPage.filePathRelative)
return md.render(content, cleanMarkdownEnv(internalPage.markdownEnv))
}
// 其他资源,解析为链接

View File

@ -1,42 +0,0 @@
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

@ -4,14 +4,11 @@ import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
import { isPlainObject } from 'vuepress/shared'
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,
app: App,
options: MarkdownPowerPluginOptions,
) {
if (options.obsidian === false)
@ -19,10 +16,8 @@ export function obsidianPlugin(
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
initPagePaths(app)
if (obsidian.wikiLink !== false)
wikiLinkPlugin(md)
wikiLinkPlugin(md, app)
if (obsidian.embedLink !== false)
embedLinkPlugin(md, app)

View File

@ -9,12 +9,13 @@
*/
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
import type { App } from 'vuepress'
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
import { sortBy } from '@pengzhanbo/utils'
import { ensureLeadingSlash, isLinkHttp, removeLeadingSlash } 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
@ -84,7 +85,7 @@ const wikiLinkDef: RuleInline = (state, silent) => {
return true
}
export function wikiLinkPlugin(md: Markdown) {
export function wikiLinkPlugin(md: Markdown, app: App) {
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
const token = tokens[idx]
@ -102,19 +103,19 @@ export function wikiLinkPlugin(md: Markdown) {
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 internal = findFirstPage(app, filename, env.filePathRelative ?? '')
if (internal) {
const { absolutePath, relativePath } = resolvePaths(
pagePath,
internal.filePathRelative!,
env.base || '/',
env.filePathRelative ?? null,
)
;(env.links ??= []).push({
raw: pagePath,
raw: internal.filePathRelative!,
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>`
return `<VPLink href="${internal.path}${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
}
// other asset url
@ -125,3 +126,30 @@ export function wikiLinkPlugin(md: Markdown) {
}</a>`
}
}
export function findFirstPage(app: App, filename: string, relativePath: string) {
const dirname = path.dirname(relativePath)
const withExt = path.extname(filename) ? filename : `${filename}.md`
const sorted = sortBy(app.pages ?? [], page => page.filePathRelative?.split('/').length ?? Infinity)
return sorted.find((page) => {
const title = page.title || page.frontmatter?.title || page.data.title
// 匹配标题, 优先从最短路径开始匹配
if (title === filename)
return true
const relative = page.filePathRelative
/* istanbul ignore if -- @preserve */
if (!relative)
return false
const filepath = filename[0] === '.' ? path.join(dirname, filename) : removeLeadingSlash(filename)
// 精确匹配
if ((filepath.slice(-1) === '/' && (relative === `${filepath}README.md` || relative === `${filepath}index.html`)) || relative === withExt) {
return true
}
// 模糊匹配优先从最短路径匹配sorted 已按照路径长度排序
return (filepath.slice(-1) === '/' && (relative.endsWith(`${filepath}README.md`) || relative.endsWith(`${filepath}index.html`))) || relative.endsWith(withExt)
})
}

View File

@ -13,7 +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 { obsidianPlugin } from './obsidian/index.js'
import { prepareConfigFile } from './prepareConfigFile.js'
import { provideData } from './provideData.js'
@ -106,14 +106,13 @@ export function markdownPowerPlugin(
embedSyntaxPlugin(md, options)
inlineSyntaxPlugin(md, options)
iconPlugin(md, options.icon ?? (isPlainObject(options.icons) ? options.icons : {}))
obsidianPlugin(md, app, options)
if (options.demo)
demoPlugin(app, md)
await containerPlugin(app, md, options, locales)
await imageSizePlugin(app, md, options.imageSize)
obsidianPlugin(app, md, options)
},
onPrepared: async () => {
@ -134,13 +133,6 @@ 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

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

58
pnpm-lock.yaml generated
View File

@ -137,8 +137,8 @@ catalogs:
version: 9.0.5
peer:
'@iconify/json':
specifier: ^2.2.464
version: 2.2.464
specifier: ^2.2.463
version: 2.2.463
'@mathjax/src':
specifier: ^4.1.1
version: 4.1.1
@ -398,12 +398,10 @@ overrides:
'@shikijs/twoslash': ^4.0.2
'@typescript-eslint/types': ^8.58.2
'@typescript-eslint/utils': ^8.58.2
'@xmldom/xmldom': '>=0.9.10'
baseline-browser-mapping: ^2.10.20
'@xmldom/xmldom': '>=0.9.9'
baseline-browser-mapping: ^2.10.19
chokidar: 5.0.0
dompurify: '>=3.4.0'
esbuild: ^0.28.0
follow-redirects: '>=1.16.0'
lodash: '>=4.18.1'
lodash-es: '>=4.18.1'
sass-embedded: ^1.99.0
@ -567,7 +565,7 @@ importers:
dependencies:
'@iconify/json':
specifier: catalog:peer
version: 2.2.464
version: 2.2.463
'@lunariajs/core':
specifier: catalog:dev
version: 0.1.1
@ -631,7 +629,7 @@ importers:
dependencies:
'@iconify/json':
specifier: catalog:peer
version: 2.2.464
version: 2.2.463
'@vuepress/bundler-vite':
specifier: catalog:vuepress
version: 2.0.0-rc.28(@types/node@25.6.0)(@vue/compiler-sfc@3.5.32)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.64.0)(typescript@6.0.3)(yaml@2.8.3)
@ -652,7 +650,7 @@ importers:
dependencies:
'@iconify/json':
specifier: catalog:peer
version: 2.2.464
version: 2.2.463
'@vuepress/bundler-vite':
specifier: catalog:vuepress
version: 2.0.0-rc.28(@types/node@25.6.0)(@vue/compiler-sfc@3.5.32)(esbuild@0.28.0)(jiti@2.6.1)(less@4.6.4)(sass-embedded@1.99.0)(sass@1.99.0)(stylus@0.64.0)(typescript@6.0.3)(yaml@2.8.3)
@ -960,7 +958,7 @@ importers:
devDependencies:
'@iconify/json':
specifier: catalog:peer
version: 2.2.464
version: 2.2.463
'@pinyin-pro/data':
specifier: catalog:peer
version: 1.3.1
@ -1513,8 +1511,8 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify/json@2.2.464':
resolution: {integrity: sha512-VSU7hPHuqqEwQk8Hmy+88iGmsL6KE2JAv1ZwxE0z50+JpymIzhZMHcuSrL2MOFtTDZQ4eJ7Ao9Hdm+TCctDwCQ==}
'@iconify/json@2.2.463':
resolution: {integrity: sha512-VZ0n+99OWe9677b04KPF0NajDbFEyWNxMalXZA/4j8HrqyVvY+N1XN/EIER4ceQlKQJ501w9UxLJZjZ5mga0xA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@ -3373,8 +3371,8 @@ packages:
peerDependencies:
vue: ^3.5.0
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
'@xmldom/xmldom@0.9.9':
resolution: {integrity: sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==}
engines: {node: '>=14.6'}
acorn-jsx@5.3.2:
@ -3549,8 +3547,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.20:
resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==}
baseline-browser-mapping@2.10.19:
resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==}
engines: {node: '>=6.0.0'}
hasBin: true
@ -4224,8 +4222,8 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dompurify@3.4.0:
resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==}
dompurify@3.3.3:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@ -4738,8 +4736,8 @@ packages:
focus-trap@8.0.1:
resolution: {integrity: sha512-9ptSG6z51YQOstI/oN4XuVGP/03u2nh0g//qz7L6zX0i6PZiPnkcf3GenXq7N2hZnASXaMxTPpbKwdI+PFvxlw==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
@ -8233,7 +8231,7 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@iconify/json@2.2.464':
'@iconify/json@2.2.463':
dependencies:
'@iconify/types': 2.0.0
pathe: 2.0.3
@ -10273,7 +10271,7 @@ snapshots:
dependencies:
vue: 3.5.32(typescript@6.0.3)
'@xmldom/xmldom@0.9.10': {}
'@xmldom/xmldom@0.9.9': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
@ -10434,7 +10432,7 @@ snapshots:
axios@1.15.0:
dependencies:
follow-redirects: 1.16.0
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 2.1.0
transitivePeerDependencies:
@ -10450,7 +10448,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.20: {}
baseline-browser-mapping@2.10.19: {}
basic-auth@2.0.1:
dependencies:
@ -10500,7 +10498,7 @@ snapshots:
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.20
baseline-browser-mapping: 2.10.19
caniuse-lite: 1.0.30001784
electron-to-chromium: 1.5.331
node-releases: 2.0.37
@ -11199,7 +11197,7 @@ snapshots:
dependencies:
domelementtype: 2.3.0
dompurify@3.4.0:
dompurify@3.3.3:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -11865,7 +11863,7 @@ snapshots:
dependencies:
tabbable: 6.4.0
follow-redirects@1.16.0: {}
follow-redirects@1.15.11: {}
for-each@0.3.5:
dependencies:
@ -12233,7 +12231,7 @@ snapshots:
http-proxy@1.18.1:
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.16.0
follow-redirects: 1.15.11
requires-port: 1.0.0
transitivePeerDependencies:
- debug
@ -13093,7 +13091,7 @@ snapshots:
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.20
dompurify: 3.4.0
dompurify: 3.3.3
katex: 0.16.44
khroma: 2.1.0
lodash-es: 4.18.1
@ -14307,7 +14305,7 @@ snapshots:
speech-rule-engine@5.0.0-beta.6:
dependencies:
'@xmldom/xmldom': 0.9.10
'@xmldom/xmldom': 0.9.9
commander: 14.0.2
wicked-good-xpath: 1.3.0

View File

@ -67,7 +67,7 @@ catalogs:
wait-on: ^9.0.5
peer:
'@eslint-community/eslint-utils': ^4.9.1
'@iconify/json': ^2.2.464
'@iconify/json': ^2.2.463
'@mathjax/src': ^4.1.1
'@pinyin-pro/data': ^1.3.1
'@typescript-eslint/types': ^8.58.2

View File

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

View File

@ -46,11 +46,12 @@ 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[]): Matcher {
export function createMatcher(include?: string | string[], exclude?: string | string[], cwd?: string): Matcher {
exclude = ['**/node_modules/**', '**/.vuepress/**', ...toArray(exclude)]
const { pattern, ignore } = resolveMatcherPattern(include, exclude)
return picomatch(pattern, { ignore })
return picomatch(pattern, { ignore, cwd })
}