mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
refactor(plugin-md-power): refactor obsidian plugins (#893)
This commit is contained in:
parent
58ea2fc8cb
commit
402f259086
@ -7,8 +7,7 @@ permalink: /en/guide/markdown/obsidian/
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The theme provides compatibility support for Obsidian's official Markdown extension syntax through the `vuepress-plugin-md-power` plugin,
|
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.
|
||||||
enabling Obsidian users to write documentation using familiar syntax.
|
|
||||||
|
|
||||||
Currently supported Obsidian extension syntax includes:
|
Currently supported Obsidian extension syntax includes:
|
||||||
|
|
||||||
@ -21,7 +20,7 @@ Currently supported Obsidian extension syntax includes:
|
|||||||
|
|
||||||
## Wiki Links
|
## Wiki Links
|
||||||
|
|
||||||
Wiki Links are syntax for linking to other notes in Obsidian.
|
Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links.
|
||||||
|
|
||||||
### Syntax
|
### Syntax
|
||||||
|
|
||||||
@ -31,6 +30,7 @@ Wiki Links are syntax for linking to other notes in Obsidian.
|
|||||||
[[filename#heading#subheading]]
|
[[filename#heading#subheading]]
|
||||||
[[filename|alias]]
|
[[filename|alias]]
|
||||||
[[filename#heading|alias]]
|
[[filename#heading|alias]]
|
||||||
|
[[https://example.com|External Link]]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Filename Search Rules
|
### Filename Search Rules
|
||||||
@ -39,15 +39,14 @@ When using Wiki Links, filenames are matched according to the following rules:
|
|||||||
|
|
||||||
**Match Priority:**
|
**Match Priority:**
|
||||||
|
|
||||||
1. **Page Title** - Priority matching against page titles
|
1. **Full Path** - Exact match against file paths
|
||||||
2. **Full Path** - Exact match against file paths
|
2. **Fuzzy Match** - Match filenames at the end of paths, prioritizing the shortest path
|
||||||
3. **Fuzzy Match** - Match filenames at the end of paths
|
|
||||||
|
|
||||||
**Path Resolution Rules:**
|
**Path Resolution Rules:**
|
||||||
|
|
||||||
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
|
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
|
||||||
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, with shortest path taking precedence
|
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, prioritizing the shortest path
|
||||||
- **Directory form** (ending with `/`): Matches `README.md` or `index.html` within that directory
|
- **Directory form** (ending with `/`): Matches `README.md` in that directory
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
|
|
||||||
@ -55,9 +54,9 @@ Assuming the following document structure:
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
docs/
|
docs/
|
||||||
├── README.md (title: "Home")
|
├── README.md
|
||||||
├── guide/
|
├── guide/
|
||||||
│ ├── README.md (title: "Guide")
|
│ ├── README.md
|
||||||
│ └── markdown/
|
│ └── markdown/
|
||||||
│ └── obsidian.md
|
│ └── obsidian.md
|
||||||
```
|
```
|
||||||
@ -65,9 +64,8 @@ docs/
|
|||||||
In `docs/guide/markdown/obsidian.md`:
|
In `docs/guide/markdown/obsidian.md`:
|
||||||
|
|
||||||
| Syntax | Match Result |
|
| Syntax | Match Result |
|
||||||
| ------------ | ------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| `[[Home]]` | Matches `docs/README.md` (via title) |
|
| `[[obsidian]]` | Matches `docs/guide/markdown/obsidian.md` (matched via filename) |
|
||||||
| `[[Guide]]` | Matches `docs/guide/README.md` (via title) |
|
|
||||||
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
|
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
|
||||||
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
|
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
|
||||||
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
|
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
|
||||||
@ -86,31 +84,26 @@ In `docs/guide/markdown/obsidian.md`:
|
|||||||
|
|
||||||
[[https://example.com|External Link]]
|
[[https://example.com|External Link]]
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Internal Anchor Links:**
|
**Internal Anchor Links:**
|
||||||
|
|
||||||
**Input:**
|
**Input:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
[[QR Code]] <!-- Search by title -->
|
|
||||||
[[npm-to]] <!-- Search by filename -->
|
[[npm-to]] <!-- Search by filename -->
|
||||||
[[guide/markdown/math]] <!-- Search by file path -->
|
[[guide/markdown/math]] <!-- Search by file path -->
|
||||||
[[#Wiki Links]] <!-- Heading on current page -->
|
[[#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:**
|
**Output:**
|
||||||
|
|
||||||
[[QR Code]]
|
|
||||||
|
|
||||||
[[npm-to]]
|
[[npm-to]]
|
||||||
|
|
||||||
[[guide/markdown/math]]
|
[[guide/markdown/math]]
|
||||||
|
|
||||||
[[#Wiki Links]]
|
[[#Wiki Links]]
|
||||||
|
|
||||||
[[file-tree#configuration]]
|
[[file-tree#Configuration]]
|
||||||
|
|
||||||
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
|
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
|
||||||
|
|
||||||
@ -136,22 +129,38 @@ Filename search rules are the same as [Wiki Links](#filename-search-rules).
|
|||||||
**Syntax:**
|
**Syntax:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[image.png]]
|
![[image]]
|
||||||
![[image.png|300]]
|
![[image|300]]
|
||||||
![[image.png|300x200]]
|
![[image|300x200]]
|
||||||
```
|
```
|
||||||
|
|
||||||
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
||||||
|
|
||||||
**Input:**
|
**Example:**
|
||||||
|
|
||||||
|
::: demo markdown title="Basic Image" expanded
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[images/custom-hero.jpg]]
|
![[images/custom-hero.jpg]]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output:**
|
:::
|
||||||
|
|
||||||
![[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
|
### PDF Embeds
|
||||||
|
|
||||||
@ -163,45 +172,37 @@ Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `i
|
|||||||
```md
|
```md
|
||||||
![[document.pdf]]
|
![[document.pdf]]
|
||||||
![[document.pdf#page=1]] <!-- #page=1 means first page -->
|
![[document.pdf#page=1]] <!-- #page=1 means first page -->
|
||||||
![[document.pdf#page=1#height=300]] <!-- #height=300 means height of 300px -->
|
![[document.pdf#page=1#height=300]] <!-- #page=page number #height=height -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Supported formats: `pdf`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Audio Embeds
|
### Audio Embeds
|
||||||
|
|
||||||
> [!note]
|
**Syntax:**
|
||||||
> Audio embeds require the file path to be correct and the file to exist in the document directory.
|
|
||||||
|
|
||||||
**Input:**
|
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[audio.mp3]]
|
![[audio file]]
|
||||||
```
|
```
|
||||||
|
|
||||||
**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`
|
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Video Embeds
|
### Video Embeds
|
||||||
|
|
||||||
> [!note]
|
> [!NOTE]
|
||||||
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
|
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
|
||||||
|
|
||||||
**Input:**
|
**Syntax:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[video.mp4]]
|
![[video file]]
|
||||||
|
![[video file#height=400]] <!-- Set video height -->
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
![[https://artplayer.org/assets/sample/video.mp4]]
|
|
||||||
|
|
||||||
Supported formats: `mp4`, `webm`, `mov`, etc.
|
Supported formats: `mp4`, `webm`, `mov`, etc.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -214,12 +215,12 @@ Content fragments under a specified heading can be embedded using `#heading`:
|
|||||||
|
|
||||||
```md
|
```md
|
||||||
![[my-note]]
|
![[my-note]]
|
||||||
![[my-note#heading-one]]
|
![[my-note#Heading One]]
|
||||||
![[my-note#heading-one#subheading]]
|
![[my-note#Heading One#Subheading]]
|
||||||
```
|
```
|
||||||
|
|
||||||
[Obsidian Official - Embeds](https://obsidian.md/en/help/embeds){.readmore}
|
[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 - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore}
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
@ -280,24 +281,21 @@ Content before the comment
|
|||||||
|
|
||||||
%%
|
%%
|
||||||
This is a block comment.
|
This is a block comment.
|
||||||
|
|
||||||
It can span multiple lines.
|
|
||||||
%%
|
%%
|
||||||
|
|
||||||
Content after the comment
|
It can span multiple lines.
|
||||||
|
|
||||||
> Related Documentation: [Obsidian Official - Comments](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B)
|
[Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore}
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
You can enable or disable these plugins in the theme configuration:
|
Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration:
|
||||||
|
|
||||||
```ts title=".vuepress/config.ts"
|
```ts title=".vuepress/config.ts"
|
||||||
export default defineUserConfig({
|
export default defineUserConfig({
|
||||||
theme: plumeTheme({
|
theme: plumeTheme({
|
||||||
plugins: {
|
plugins: {
|
||||||
mdPower: {
|
mdPower: {
|
||||||
// Obsidian compatibility plugin configuration
|
|
||||||
obsidian: {
|
obsidian: {
|
||||||
wikiLink: true, // Wiki Links
|
wikiLink: true, // Wiki Links
|
||||||
embedLink: true, // Embeds
|
embedLink: true, // Embeds
|
||||||
@ -316,15 +314,15 @@ export default defineUserConfig({
|
|||||||
:::: field-group
|
:::: field-group
|
||||||
|
|
||||||
::: field name="wikiLink" type="boolean" default="true" optional
|
::: field name="wikiLink" type="boolean" default="true" optional
|
||||||
Enable Wiki Links syntax
|
Enable Wiki Links syntax.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: field name="embedLink" type="boolean" default="true" optional
|
::: field name="embedLink" type="boolean" default="true" optional
|
||||||
Enable embed content syntax
|
Enable embed content syntax.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: field name="comment" type="boolean" default="true" optional
|
::: field name="comment" type="boolean" default="true" optional
|
||||||
Enable comment syntax
|
Enable comment syntax.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::::
|
::::
|
||||||
@ -332,7 +330,8 @@ Enable comment syntax
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality
|
- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality
|
||||||
- Some Obsidian-specific features (such as the graph view for internal links, bidirectional links, etc.) are outside the scope of this support
|
- 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
|
- When embedding content, the embedded page also participates in the theme's build process
|
||||||
- PDF embeds require the `pdf` plugin to be enabled simultaneously
|
- PDF embeds require the `markdown.pdf` plugin to be enabled simultaneously
|
||||||
- Video embeds require the `artPlayer` 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
|
||||||
|
|||||||
@ -15,12 +15,12 @@ permalink: /guide/markdown/obsidian/
|
|||||||
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
|
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
|
||||||
- [注释](#注释) - 添加仅在编辑时可见的注释
|
- [注释](#注释) - 添加仅在编辑时可见的注释
|
||||||
|
|
||||||
::: warning 不计划对 obsidian 社区的第三方插件提供的扩展语法进行支持
|
::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Wiki 链接
|
## Wiki 链接
|
||||||
|
|
||||||
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
|
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。
|
||||||
|
|
||||||
### 语法
|
### 语法
|
||||||
|
|
||||||
@ -30,6 +30,7 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
|
|||||||
[[文件名#标题#子标题]]
|
[[文件名#标题#子标题]]
|
||||||
[[文件名|别名]]
|
[[文件名|别名]]
|
||||||
[[文件名#标题|别名]]
|
[[文件名#标题|别名]]
|
||||||
|
[[https://example.com|外部链接]]
|
||||||
```
|
```
|
||||||
|
|
||||||
### 文件名搜索规则
|
### 文件名搜索规则
|
||||||
@ -38,15 +39,14 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
|
|||||||
|
|
||||||
**匹配优先级:**
|
**匹配优先级:**
|
||||||
|
|
||||||
1. **页面标题** - 优先匹配页面的标题
|
1. **完整路径** - 精确匹配文件路径
|
||||||
2. **完整路径** - 精确匹配文件路径
|
2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径
|
||||||
3. **模糊匹配** - 匹配路径结尾的文件名
|
|
||||||
|
|
||||||
**路径解析规则:**
|
**路径解析规则:**
|
||||||
|
|
||||||
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
|
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
|
||||||
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
|
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
|
||||||
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md` 或 `index.html`
|
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md`
|
||||||
|
|
||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
@ -54,9 +54,9 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
|
|||||||
|
|
||||||
```txt
|
```txt
|
||||||
docs/
|
docs/
|
||||||
├── README.md (title: "首页")
|
├── README.md
|
||||||
├── guide/
|
├── guide/
|
||||||
│ ├── README.md (title: "指南")
|
│ ├── README.md
|
||||||
│ └── markdown/
|
│ └── markdown/
|
||||||
│ └── obsidian.md
|
│ └── obsidian.md
|
||||||
```
|
```
|
||||||
@ -64,9 +64,8 @@ docs/
|
|||||||
在 `docs/guide/markdown/obsidian.md` 中:
|
在 `docs/guide/markdown/obsidian.md` 中:
|
||||||
|
|
||||||
| 语法 | 匹配结果 |
|
| 语法 | 匹配结果 |
|
||||||
| ------------ | ------------------------------------------------ |
|
| -------------- | -------------------------------------------------------- |
|
||||||
| `[[首页]]` | 匹配 `docs/README.md`(通过标题) |
|
| `[[obsidian]]` | 匹配 `docs/guide/markdown/obsidian.md`(通过文件名检索) |
|
||||||
| `[[指南]]` | 匹配 `docs/guide/README.md`(通过标题) |
|
|
||||||
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
|
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
|
||||||
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
|
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
|
||||||
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
|
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
|
||||||
@ -90,7 +89,6 @@ docs/
|
|||||||
**输入:**
|
**输入:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
[[二维码]] <!-- 通过标题检索 -->
|
|
||||||
[[npm-to]] <!-- 通过文件名检索 -->
|
[[npm-to]] <!-- 通过文件名检索 -->
|
||||||
[[guide/markdown/math]] <!-- 通过文件路径检索-->
|
[[guide/markdown/math]] <!-- 通过文件路径检索-->
|
||||||
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
|
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
|
||||||
@ -99,8 +97,6 @@ docs/
|
|||||||
|
|
||||||
**输出:**
|
**输出:**
|
||||||
|
|
||||||
[[二维码]]
|
|
||||||
|
|
||||||
[[npm-to]]
|
[[npm-to]]
|
||||||
|
|
||||||
[[guide/markdown/math]]
|
[[guide/markdown/math]]
|
||||||
@ -133,22 +129,38 @@ docs/
|
|||||||
**语法:**
|
**语法:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[image.png]]
|
![[图片]]
|
||||||
![[image.png|300]]
|
![[图片|宽度]]
|
||||||
![[image.png|300x200]]
|
![[图片|宽度x高度]]
|
||||||
```
|
```
|
||||||
|
|
||||||
支持格式:`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
|
```md
|
||||||
![[images/custom-hero.jpg]]
|
![[images/custom-hero.jpg]]
|
||||||
```
|
```
|
||||||
|
|
||||||
**输出:**
|
:::
|
||||||
|
|
||||||
![[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 嵌入
|
### PDF 嵌入
|
||||||
|
|
||||||
@ -158,48 +170,40 @@ docs/
|
|||||||
**语法:**
|
**语法:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[document.pdf]]
|
![[文档.pdf]]
|
||||||
![[document.pdf#page=1]] <!-- #page=1 表示第一页 -->
|
![[文档.pdf#page=1]] <!-- #page=1 表示第一页 -->
|
||||||
![[document.pdf#page=1#height=300]] <!-- #height=300 表示高度为 300px -->
|
![[文档.pdf#page=1#height=300]] <!-- #page=页码 #height=高度 -->
|
||||||
```
|
```
|
||||||
|
|
||||||
|
支持格式:`pdf`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 音频嵌入
|
### 音频嵌入
|
||||||
|
|
||||||
> [!note]
|
**语法:**
|
||||||
> 音频嵌入需要确保文件路径正确,文件存在于文档目录中。
|
|
||||||
|
|
||||||
**输入:**
|
|
||||||
|
|
||||||
```md
|
```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` 插件才能正常工作。
|
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
|
||||||
|
|
||||||
**输入:**
|
**语法:**
|
||||||
|
|
||||||
```md
|
```md
|
||||||
![[video.mp4]]
|
![[视频文件]]
|
||||||
|
![[视频文件#height=400]] <!-- 设置视频高度 -->
|
||||||
```
|
```
|
||||||
|
|
||||||
**输出:**
|
支持格式:`mp4`、`webm`、`mov` 等
|
||||||
|
|
||||||
![[https://artplayer.org/assets/sample/video.mp4]]
|
|
||||||
|
|
||||||
支持格式:`mp4`, `webm`, `mov` 等
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -277,24 +281,21 @@ docs/
|
|||||||
|
|
||||||
%%
|
%%
|
||||||
这是一个块级注释。
|
这是一个块级注释。
|
||||||
|
|
||||||
可以跨越多行。
|
|
||||||
%%
|
%%
|
||||||
|
|
||||||
注释之后的内容
|
可以跨越多行。
|
||||||
|
|
||||||
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
|
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
你可以在主题配置中启用或禁用这些插件:
|
Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用:
|
||||||
|
|
||||||
```ts title=".vuepress/config.ts"
|
```ts title=".vuepress/config.ts"
|
||||||
export default defineUserConfig({
|
export default defineUserConfig({
|
||||||
theme: plumeTheme({
|
theme: plumeTheme({
|
||||||
plugins: {
|
plugins: {
|
||||||
mdPower: {
|
mdPower: {
|
||||||
// Obsidian 兼容插件配置
|
|
||||||
obsidian: {
|
obsidian: {
|
||||||
wikiLink: true, // Wiki 链接
|
wikiLink: true, // Wiki 链接
|
||||||
embedLink: true, // 嵌入内容
|
embedLink: true, // 嵌入内容
|
||||||
@ -313,15 +314,15 @@ export default defineUserConfig({
|
|||||||
:::: field-group
|
:::: field-group
|
||||||
|
|
||||||
::: field name="wikiLink" type="boolean" default="true" optional
|
::: field name="wikiLink" type="boolean" default="true" optional
|
||||||
启用 Wiki 链接语法
|
启用 Wiki 链接语法。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: field name="embedLink" type="boolean" default="true" optional
|
::: field name="embedLink" type="boolean" default="true" optional
|
||||||
启用嵌入内容语法
|
启用嵌入内容语法。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: field name="comment" type="boolean" default="true" optional
|
::: field name="comment" type="boolean" default="true" optional
|
||||||
启用注释语法
|
启用注释语法。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::::
|
::::
|
||||||
@ -331,5 +332,6 @@ export default defineUserConfig({
|
|||||||
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
|
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
|
||||||
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
|
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
|
||||||
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
|
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
|
||||||
- PDF 嵌入需要同时启用 `pdf` 插件
|
- PDF 嵌入需要同时启用 `markdown.pdf` 插件
|
||||||
- 视频嵌入需要同时启用 `artPlayer` 插件
|
- 视频嵌入需要同时启用 `markdown.artPlayer` 插件
|
||||||
|
- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载
|
||||||
|
|||||||
421
plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts
Normal file
421
plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
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('![[]]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,446 +0,0 @@
|
|||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
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('![[]]')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
297
plugins/plugin-md-power/__test__/obsidianExtractContent.spec.ts
Normal file
297
plugins/plugin-md-power/__test__/obsidianExtractContent.spec.ts
Normal 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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
156
plugins/plugin-md-power/__test__/obsidianFindFirstPage.spec.ts
Normal file
156
plugins/plugin-md-power/__test__/obsidianFindFirstPage.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,12 +1,28 @@
|
|||||||
import type { App } from 'vuepress'
|
import type { App } from 'vuepress'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { obsidianPlugin } from '../src/node/obsidian/index.js'
|
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 {
|
function createMockApp(pages: App['pages'] = []): App {
|
||||||
return {
|
return {
|
||||||
pages,
|
pages,
|
||||||
} as App
|
options: {
|
||||||
|
pagePatterns: ['**/*.md'],
|
||||||
|
},
|
||||||
|
dir: {
|
||||||
|
source: () => '/source',
|
||||||
|
},
|
||||||
|
} as unknown as App
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkdownWithMockRules() {
|
function createMarkdownWithMockRules() {
|
||||||
@ -20,13 +36,11 @@ describe('obsidianPlugin', () => {
|
|||||||
it('should enable all plugins by default', () => {
|
it('should enable all plugins by default', () => {
|
||||||
const md = createMarkdownWithMockRules()
|
const md = createMarkdownWithMockRules()
|
||||||
const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages'])
|
const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages'])
|
||||||
obsidianPlugin(md, mockApp, {})
|
obsidianPlugin(mockApp, md, {})
|
||||||
|
|
||||||
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]]')
|
const wikiResult = md.render('[[Home]]')
|
||||||
expect(wikiResult).toContain('<VPLink')
|
expect(wikiResult).not.toContain('<VPLink')
|
||||||
|
|
||||||
const commentResult = md.render('%%comment%%')
|
const commentResult = md.render('%%comment%%')
|
||||||
expect(commentResult).not.toContain('comment')
|
expect(commentResult).not.toContain('comment')
|
||||||
@ -35,7 +49,7 @@ describe('obsidianPlugin', () => {
|
|||||||
it('should allow disabling specific plugins', () => {
|
it('should allow disabling specific plugins', () => {
|
||||||
const md = createMarkdownWithMockRules()
|
const md = createMarkdownWithMockRules()
|
||||||
const mockApp = createMockApp()
|
const mockApp = createMockApp()
|
||||||
obsidianPlugin(md, mockApp, { obsidian: { wikiLink: false } })
|
obsidianPlugin(mockApp, md, { obsidian: { wikiLink: false } })
|
||||||
|
|
||||||
const wikiResult = md.render('[[Page]]')
|
const wikiResult = md.render('[[Page]]')
|
||||||
expect(wikiResult).not.toContain('<VPLink')
|
expect(wikiResult).not.toContain('<VPLink')
|
||||||
@ -45,17 +59,16 @@ describe('obsidianPlugin', () => {
|
|||||||
it('should disable all plugins when obsidian is false', () => {
|
it('should disable all plugins when obsidian is false', () => {
|
||||||
const md = createMarkdownWithMockRules()
|
const md = createMarkdownWithMockRules()
|
||||||
const mockApp = createMockApp()
|
const mockApp = createMockApp()
|
||||||
obsidianPlugin(md, mockApp, { obsidian: false })
|
obsidianPlugin(mockApp, md, { obsidian: false })
|
||||||
|
|
||||||
const result = md.render('![[image.png]]')
|
const result = md.render('![[image.png]]')
|
||||||
expect(result).not.toContain('<img')
|
|
||||||
expect(result).toContain('![[image.png]]')
|
expect(result).toContain('![[image.png]]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should disable embedLink when explicitly set to false', () => {
|
it('should disable embedLink when explicitly set to false', () => {
|
||||||
const md = createMarkdownWithMockRules()
|
const md = createMarkdownWithMockRules()
|
||||||
const mockApp = createMockApp()
|
const mockApp = createMockApp()
|
||||||
obsidianPlugin(md, mockApp, { obsidian: { embedLink: false } })
|
obsidianPlugin(mockApp, md, { obsidian: { embedLink: false } })
|
||||||
|
|
||||||
const result = md.render('![[image.png]]')
|
const result = md.render('![[image.png]]')
|
||||||
expect(result).not.toContain('<img')
|
expect(result).not.toContain('<img')
|
||||||
@ -64,10 +77,7 @@ describe('obsidianPlugin', () => {
|
|||||||
it('should disable comment when explicitly set to false', () => {
|
it('should disable comment when explicitly set to false', () => {
|
||||||
const md = createMarkdownWithMockRules()
|
const md = createMarkdownWithMockRules()
|
||||||
const mockApp = createMockApp()
|
const mockApp = createMockApp()
|
||||||
obsidianPlugin(md, mockApp, { obsidian: { comment: false } })
|
obsidianPlugin(mockApp, md, { obsidian: { comment: false } })
|
||||||
|
|
||||||
const embedResult = md.render('![[image.png]]')
|
|
||||||
expect(embedResult).toContain('<img')
|
|
||||||
|
|
||||||
const commentResult = md.render('%%comment%%')
|
const commentResult = md.render('%%comment%%')
|
||||||
expect(commentResult).toContain('%%comment%%')
|
expect(commentResult).toContain('%%comment%%')
|
||||||
|
|||||||
268
plugins/plugin-md-power/__test__/obsidianWikiLink.spec.ts
Normal file
268
plugins/plugin-md-power/__test__/obsidianWikiLink.spec.ts
Normal 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 > 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('> anchor1 > 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<')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,218 +0,0 @@
|
|||||||
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 > 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('> anchor1 > 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 > 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/')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -17,15 +17,16 @@
|
|||||||
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
|
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
|
||||||
import type { App } from 'vuepress'
|
import type { App } from 'vuepress'
|
||||||
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||||
|
import { attempt } from '@pengzhanbo/utils'
|
||||||
import grayMatter from 'gray-matter'
|
import grayMatter from 'gray-matter'
|
||||||
import Token from 'markdown-it/lib/token.mjs'
|
import Token from 'markdown-it/lib/token.mjs'
|
||||||
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||||
import { hash, path } from 'vuepress/utils'
|
import { fs, hash, path } from 'vuepress/utils'
|
||||||
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
|
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
|
||||||
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
||||||
import { parseRect } from '../utils/parseRect.js'
|
import { parseRect } from '../utils/parseRect.js'
|
||||||
import { slugify } from '../utils/slugify.js'
|
import { slugify } from '../utils/slugify.js'
|
||||||
import { findFirstPage } from './wikiLink.js'
|
import { findFirstPage } from './findFirstPage.js'
|
||||||
|
|
||||||
interface EmbedLinkMeta {
|
interface EmbedLinkMeta {
|
||||||
filename: string
|
filename: string
|
||||||
@ -173,13 +174,22 @@ export function embedLinkPlugin(md: Markdown, app: App): void {
|
|||||||
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
|
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||||
const token = tokens[idx]
|
const token = tokens[idx]
|
||||||
const { filename, hashes, settings } = token.meta as EmbedLinkMeta
|
const { filename, hashes, settings } = token.meta as EmbedLinkMeta
|
||||||
const internalPage = findFirstPage(app, filename, env.filePathRelative ?? '')
|
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
|
||||||
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
|
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
|
||||||
if (internalPage) {
|
if (pagePath) {
|
||||||
const { content: rawContent } = grayMatter(internalPage.content)
|
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)
|
const content = extractContentByHeadings(rawContent, hashes)
|
||||||
internalPage.filePathRelative && (env.importedFiles ??= []).push(internalPage.filePathRelative)
|
pagePath && (env.importedFiles ??= []).push(pagePath)
|
||||||
return md.render(content, cleanMarkdownEnv(internalPage.markdownEnv))
|
return md.render(content, cleanMarkdownEnv(env))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他资源,解析为链接
|
// 其他资源,解析为链接
|
||||||
|
|||||||
42
plugins/plugin-md-power/src/node/obsidian/findFirstPage.ts
Normal file
42
plugins/plugin-md-power/src/node/obsidian/findFirstPage.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -4,11 +4,14 @@ import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
|
|||||||
import { isPlainObject } from 'vuepress/shared'
|
import { isPlainObject } from 'vuepress/shared'
|
||||||
import { commentPlugin } from './comment.js'
|
import { commentPlugin } from './comment.js'
|
||||||
import { embedLinkPlugin } from './embedLink.js'
|
import { embedLinkPlugin } from './embedLink.js'
|
||||||
|
import { initPagePaths } from './findFirstPage.js'
|
||||||
import { wikiLinkPlugin } from './wikiLink.js'
|
import { wikiLinkPlugin } from './wikiLink.js'
|
||||||
|
|
||||||
|
export * from './findFirstPage.js'
|
||||||
|
|
||||||
export function obsidianPlugin(
|
export function obsidianPlugin(
|
||||||
md: Markdown,
|
|
||||||
app: App,
|
app: App,
|
||||||
|
md: Markdown,
|
||||||
options: MarkdownPowerPluginOptions,
|
options: MarkdownPowerPluginOptions,
|
||||||
) {
|
) {
|
||||||
if (options.obsidian === false)
|
if (options.obsidian === false)
|
||||||
@ -16,8 +19,10 @@ export function obsidianPlugin(
|
|||||||
|
|
||||||
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
|
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
|
||||||
|
|
||||||
|
initPagePaths(app)
|
||||||
|
|
||||||
if (obsidian.wikiLink !== false)
|
if (obsidian.wikiLink !== false)
|
||||||
wikiLinkPlugin(md, app)
|
wikiLinkPlugin(md)
|
||||||
|
|
||||||
if (obsidian.embedLink !== false)
|
if (obsidian.embedLink !== false)
|
||||||
embedLinkPlugin(md, app)
|
embedLinkPlugin(md, app)
|
||||||
|
|||||||
@ -9,13 +9,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
|
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
|
||||||
import type { App } from 'vuepress'
|
|
||||||
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||||
import { sortBy } from '@pengzhanbo/utils'
|
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||||
import { ensureLeadingSlash, isLinkHttp, removeLeadingSlash } from 'vuepress/shared'
|
|
||||||
import { path } from 'vuepress/utils'
|
import { path } from 'vuepress/utils'
|
||||||
import { resolvePaths } from '../enhance/links.js'
|
import { resolvePaths } from '../enhance/links.js'
|
||||||
import { slugify } from '../utils/slugify.js'
|
import { slugify } from '../utils/slugify.js'
|
||||||
|
import { findFirstPage } from './findFirstPage.js'
|
||||||
|
|
||||||
interface WikiLinkMeta {
|
interface WikiLinkMeta {
|
||||||
filename: string
|
filename: string
|
||||||
@ -85,7 +84,7 @@ const wikiLinkDef: RuleInline = (state, silent) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wikiLinkPlugin(md: Markdown, app: App) {
|
export function wikiLinkPlugin(md: Markdown) {
|
||||||
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
|
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
|
||||||
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
|
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||||
const token = tokens[idx]
|
const token = tokens[idx]
|
||||||
@ -103,19 +102,19 @@ export function wikiLinkPlugin(md: Markdown, app: App) {
|
|||||||
if (!filename) { // internal page 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>`
|
return `<VPLink href="${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||||
}
|
}
|
||||||
const internal = findFirstPage(app, filename, env.filePathRelative ?? '')
|
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
|
||||||
if (internal) {
|
if (pagePath) {
|
||||||
const { absolutePath, relativePath } = resolvePaths(
|
const { absolutePath, relativePath } = resolvePaths(
|
||||||
internal.filePathRelative!,
|
pagePath,
|
||||||
env.base || '/',
|
env.base || '/',
|
||||||
env.filePathRelative ?? null,
|
env.filePathRelative ?? null,
|
||||||
)
|
)
|
||||||
;(env.links ??= []).push({
|
;(env.links ??= []).push({
|
||||||
raw: internal.filePathRelative!,
|
raw: pagePath,
|
||||||
absolute: absolutePath,
|
absolute: absolutePath,
|
||||||
relative: relativePath,
|
relative: relativePath,
|
||||||
})
|
})
|
||||||
return `<VPLink href="${internal.path}${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
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
|
// other asset url
|
||||||
@ -126,30 +125,3 @@ export function wikiLinkPlugin(md: Markdown, app: App) {
|
|||||||
}</a>`
|
}</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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { linksPlugin } from './enhance/links.js'
|
|||||||
import { iconPlugin } from './icon/index.js'
|
import { iconPlugin } from './icon/index.js'
|
||||||
import { inlineSyntaxPlugin } from './inline/index.js'
|
import { inlineSyntaxPlugin } from './inline/index.js'
|
||||||
import { LOCALE_OPTIONS } from './locales/index.js'
|
import { LOCALE_OPTIONS } from './locales/index.js'
|
||||||
import { obsidianPlugin } from './obsidian/index.js'
|
import { obsidianPlugin, updatePagePaths } from './obsidian/index.js'
|
||||||
import { prepareConfigFile } from './prepareConfigFile.js'
|
import { prepareConfigFile } from './prepareConfigFile.js'
|
||||||
import { provideData } from './provideData.js'
|
import { provideData } from './provideData.js'
|
||||||
|
|
||||||
@ -106,13 +106,14 @@ export function markdownPowerPlugin(
|
|||||||
embedSyntaxPlugin(md, options)
|
embedSyntaxPlugin(md, options)
|
||||||
inlineSyntaxPlugin(md, options)
|
inlineSyntaxPlugin(md, options)
|
||||||
iconPlugin(md, options.icon ?? (isPlainObject(options.icons) ? options.icons : {}))
|
iconPlugin(md, options.icon ?? (isPlainObject(options.icons) ? options.icons : {}))
|
||||||
obsidianPlugin(md, app, options)
|
|
||||||
|
|
||||||
if (options.demo)
|
if (options.demo)
|
||||||
demoPlugin(app, md)
|
demoPlugin(app, md)
|
||||||
|
|
||||||
await containerPlugin(app, md, options, locales)
|
await containerPlugin(app, md, options, locales)
|
||||||
await imageSizePlugin(app, md, options.imageSize)
|
await imageSizePlugin(app, md, options.imageSize)
|
||||||
|
|
||||||
|
obsidianPlugin(app, md, options)
|
||||||
},
|
},
|
||||||
|
|
||||||
onPrepared: async () => {
|
onPrepared: async () => {
|
||||||
@ -133,6 +134,13 @@ export function markdownPowerPlugin(
|
|||||||
if (options.codeTree)
|
if (options.codeTree)
|
||||||
extendsPageWithCodeTree(page)
|
extendsPageWithCodeTree(page)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onPageUpdated(_app, type, newPage, oldPage) {
|
||||||
|
if (type === 'create')
|
||||||
|
updatePagePaths(newPage?.filePathRelative ?? '', 'create')
|
||||||
|
if (type === 'delete')
|
||||||
|
updatePagePaths(oldPage?.filePathRelative ?? newPage?.filePathRelative ?? '', 'delete')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user