feat(plugin-md-power): compat obsidian official markdown syntax (#890)
* feat(plugin-md-power): compat obsidian official markdown syntax * chore: tweak * chore: tweak * chore: tweak * chore: tweak
This commit is contained in:
parent
e11c7a8fcd
commit
bfd0c8409c
@ -40,8 +40,8 @@
|
||||
"sort-package-json": "catalog:prod"
|
||||
},
|
||||
"plume-deps": {
|
||||
"vuepress": "2.0.0-rc.26",
|
||||
"vue": "^3.5.26",
|
||||
"vuepress": "2.0.0-rc.28",
|
||||
"vue": "^3.5.32",
|
||||
"http-server": "^14.1.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
||||
'chat',
|
||||
'include',
|
||||
'env',
|
||||
'obsidian',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
||||
'chat',
|
||||
'include',
|
||||
'env',
|
||||
'obsidian',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -58,6 +58,7 @@ export const theme: Theme = plumeTheme({
|
||||
jsfiddle: true,
|
||||
demo: true,
|
||||
encrypt: true,
|
||||
obsidian: true,
|
||||
npmTo: ['pnpm', 'yarn', 'npm'],
|
||||
repl: {
|
||||
go: true,
|
||||
|
||||
338
docs/en/guide/markdown/obsidian.md
Normal file
338
docs/en/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,338 @@
|
||||
---
|
||||
title: Obsidian Compatibility
|
||||
icon: simple-icons:obsidian
|
||||
createTime: 2026/04/17 21:56:55
|
||||
permalink: /en/guide/markdown/obsidian/
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The theme provides compatibility support for Obsidian's official Markdown extension syntax through the `vuepress-plugin-md-power` plugin,
|
||||
enabling Obsidian users to write documentation using familiar syntax.
|
||||
|
||||
Currently supported Obsidian extension syntax includes:
|
||||
|
||||
- [Wiki Links](#wiki-links) - Syntax for inter-page linking
|
||||
- [Embeds](#embeds) - Embed content from other files into the current page
|
||||
- [Comments](#comments) - Add comments visible only during editing
|
||||
|
||||
::: warning No plans to support extension syntax provided by Obsidian's third-party community plugins
|
||||
:::
|
||||
|
||||
## Wiki Links
|
||||
|
||||
Wiki Links are syntax for linking to other notes in Obsidian.
|
||||
|
||||
### Syntax
|
||||
|
||||
```md
|
||||
[[filename]]
|
||||
[[filename#heading]]
|
||||
[[filename#heading#subheading]]
|
||||
[[filename|alias]]
|
||||
[[filename#heading|alias]]
|
||||
```
|
||||
|
||||
### Filename Search Rules
|
||||
|
||||
When using Wiki Links, filenames are matched according to the following rules:
|
||||
|
||||
**Match Priority:**
|
||||
|
||||
1. **Page Title** - Priority matching against page titles
|
||||
2. **Full Path** - Exact match against file paths
|
||||
3. **Fuzzy Match** - Match filenames at the end of paths
|
||||
|
||||
**Path Resolution Rules:**
|
||||
|
||||
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
|
||||
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, with shortest path taking precedence
|
||||
- **Directory form** (ending with `/`): Matches `README.md` or `index.html` within that directory
|
||||
|
||||
**Example:**
|
||||
|
||||
Assuming the following document structure:
|
||||
|
||||
```txt
|
||||
docs/
|
||||
├── README.md (title: "Home")
|
||||
├── guide/
|
||||
│ ├── README.md (title: "Guide")
|
||||
│ └── markdown/
|
||||
│ └── obsidian.md
|
||||
```
|
||||
|
||||
In `docs/guide/markdown/obsidian.md`:
|
||||
|
||||
| Syntax | Match Result |
|
||||
| ------------ | ------------------------------------------------------- |
|
||||
| `[[Home]]` | Matches `docs/README.md` (via title) |
|
||||
| `[[Guide]]` | Matches `docs/guide/README.md` (via title) |
|
||||
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
|
||||
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
|
||||
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
|
||||
|
||||
### Examples
|
||||
|
||||
**External Links:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
[[https://example.com|External Link]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
[[https://example.com|External Link]]
|
||||
|
||||
---
|
||||
|
||||
**Internal Anchor Links:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
[[QR Code]] <!-- Search by title -->
|
||||
[[npm-to]] <!-- Search by filename -->
|
||||
[[guide/markdown/math]] <!-- Search by file path -->
|
||||
[[#Wiki Links]] <!-- Heading on current page -->
|
||||
[[file-tree#configuration]] <!-- Search by filename, link to heading -->
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
[[QR Code]]
|
||||
|
||||
[[npm-to]]
|
||||
|
||||
[[guide/markdown/math]]
|
||||
|
||||
[[#Wiki Links]]
|
||||
|
||||
[[file-tree#configuration]]
|
||||
|
||||
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
|
||||
|
||||
## Embeds
|
||||
|
||||
The embed syntax allows you to insert other file resources into the current page.
|
||||
|
||||
### Syntax
|
||||
|
||||
```md
|
||||
![[filename]]
|
||||
![[filename#heading]]
|
||||
![[filename#heading#subheading]]
|
||||
```
|
||||
|
||||
Filename search rules are the same as [Wiki Links](#filename-search-rules).
|
||||
|
||||
::: info Resources starting with `/` or having no path prefix like `./` are loaded from the `public` directory
|
||||
:::
|
||||
|
||||
### Image Embeds
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[image.png]]
|
||||
![[image.png|300]]
|
||||
![[image.png|300x200]]
|
||||
```
|
||||
|
||||
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
![[images/custom-hero.jpg]]
|
||||
|
||||
### PDF Embeds
|
||||
|
||||
> [!NOTE]
|
||||
> PDF embeds require the `markdown.pdf` plugin to be enabled for proper functionality.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[document.pdf]]
|
||||
![[document.pdf#page=1]] <!-- #page=1 means first page -->
|
||||
![[document.pdf#page=1#height=300]] <!-- #height=300 means height of 300px -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Audio Embeds
|
||||
|
||||
> [!note]
|
||||
> Audio embeds require the file path to be correct and the file to exist in the document directory.
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
![[audio.mp3]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]]
|
||||
|
||||
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
|
||||
|
||||
---
|
||||
|
||||
### Video Embeds
|
||||
|
||||
> [!note]
|
||||
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
![[video.mp4]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
![[https://artplayer.org/assets/sample/video.mp4]]
|
||||
|
||||
Supported formats: `mp4`, `webm`, `mov`, etc.
|
||||
|
||||
---
|
||||
|
||||
### Content Fragment Embeds
|
||||
|
||||
Content fragments under a specified heading can be embedded using `#heading`:
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
![[my-note]]
|
||||
![[my-note#heading-one]]
|
||||
![[my-note#heading-one#subheading]]
|
||||
```
|
||||
|
||||
[Obsidian Official - Embeds](https://obsidian.md/en/help/embeds){.readmore}
|
||||
[Obsidian Official - File Formats](https://obsidian.md/en/help/file-formats){.readmore}
|
||||
|
||||
## Comments
|
||||
|
||||
Content wrapped in `%%` is treated as a comment and will not be rendered on the page.
|
||||
|
||||
### Syntax
|
||||
|
||||
**Inline Comments:**
|
||||
|
||||
```md
|
||||
This is an %%inline comment%% example.
|
||||
```
|
||||
|
||||
**Block Comments:**
|
||||
|
||||
```md
|
||||
%%
|
||||
This is a block comment.
|
||||
It can span multiple lines.
|
||||
%%
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Inline Comments:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
This is an %%inline comment%% example.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
This is an %%inline comment%% example.
|
||||
|
||||
---
|
||||
|
||||
**Block Comments:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
Content before the comment
|
||||
|
||||
%%
|
||||
This is a block comment.
|
||||
|
||||
It can span multiple lines.
|
||||
%%
|
||||
|
||||
Content after the comment
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
Content before the comment
|
||||
|
||||
%%
|
||||
This is a block comment.
|
||||
|
||||
It can span multiple lines.
|
||||
%%
|
||||
|
||||
Content after the comment
|
||||
|
||||
> Related Documentation: [Obsidian Official - Comments](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B)
|
||||
|
||||
## Configuration
|
||||
|
||||
You can enable or disable these plugins in the theme configuration:
|
||||
|
||||
```ts title=".vuepress/config.ts"
|
||||
export default defineUserConfig({
|
||||
theme: plumeTheme({
|
||||
plugins: {
|
||||
mdPower: {
|
||||
// Obsidian compatibility plugin configuration
|
||||
obsidian: {
|
||||
wikiLink: true, // Wiki Links
|
||||
embedLink: true, // Embeds
|
||||
comment: true, // Comments
|
||||
},
|
||||
pdf: true, // PDF embed functionality
|
||||
artPlayer: true, // Video embed functionality
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
:::: field-group
|
||||
|
||||
::: field name="wikiLink" type="boolean" default="true" optional
|
||||
Enable Wiki Links syntax
|
||||
:::
|
||||
|
||||
::: field name="embedLink" type="boolean" default="true" optional
|
||||
Enable embed content syntax
|
||||
:::
|
||||
|
||||
::: field name="comment" type="boolean" default="true" optional
|
||||
Enable comment syntax
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## Notes
|
||||
|
||||
- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality
|
||||
- Some Obsidian-specific features (such as the graph view for internal links, bidirectional links, etc.) are outside the scope of this support
|
||||
- When embedding content, the embedded page also participates in the theme's build process
|
||||
- PDF embeds require the `pdf` plugin to be enabled simultaneously
|
||||
- Video embeds require the `artPlayer` plugin to be enabled simultaneously
|
||||
335
docs/guide/markdown/obsidian.md
Normal file
335
docs/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,335 @@
|
||||
---
|
||||
title: Obsidian 兼容
|
||||
icon: simple-icons:obsidian
|
||||
createTime: 2026/04/17 21:56:55
|
||||
permalink: /guide/markdown/obsidian/
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
主题通过 `vuepress-plugin-md-power` 插件提供对 Obsidian 官方 Markdown 扩展语法的兼容性支持,使 Obsidian 用户能够以熟悉的语法撰写文档。
|
||||
|
||||
当前已支持的 Obsidian 扩展语法包括:
|
||||
|
||||
- [Wiki 链接](#wiki-链接) - 页面间相互链接的语法
|
||||
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
|
||||
- [注释](#注释) - 添加仅在编辑时可见的注释
|
||||
|
||||
::: warning 不计划对 obsidian 社区的第三方插件提供的扩展语法进行支持
|
||||
:::
|
||||
|
||||
## Wiki 链接
|
||||
|
||||
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。
|
||||
|
||||
### 语法
|
||||
|
||||
```md
|
||||
[[文件名]]
|
||||
[[文件名#标题]]
|
||||
[[文件名#标题#子标题]]
|
||||
[[文件名|别名]]
|
||||
[[文件名#标题|别名]]
|
||||
```
|
||||
|
||||
### 文件名搜索规则
|
||||
|
||||
当使用 Wiki 链接时,文件名会按照以下规则进行搜索匹配:
|
||||
|
||||
**匹配优先级:**
|
||||
|
||||
1. **页面标题** - 优先匹配页面的标题
|
||||
2. **完整路径** - 精确匹配文件路径
|
||||
3. **模糊匹配** - 匹配路径结尾的文件名
|
||||
|
||||
**路径解析规则:**
|
||||
|
||||
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
|
||||
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
|
||||
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md` 或 `index.html`
|
||||
|
||||
**示例:**
|
||||
|
||||
假设文档结构如下:
|
||||
|
||||
```txt
|
||||
docs/
|
||||
├── README.md (title: "首页")
|
||||
├── guide/
|
||||
│ ├── README.md (title: "指南")
|
||||
│ └── markdown/
|
||||
│ └── obsidian.md
|
||||
```
|
||||
|
||||
在 `docs/guide/markdown/obsidian.md` 中:
|
||||
|
||||
| 语法 | 匹配结果 |
|
||||
| ------------ | ------------------------------------------------ |
|
||||
| `[[首页]]` | 匹配 `docs/README.md`(通过标题) |
|
||||
| `[[指南]]` | 匹配 `docs/guide/README.md`(通过标题) |
|
||||
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
|
||||
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
|
||||
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
|
||||
|
||||
### 示例
|
||||
|
||||
**外部链接:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
[[https://example.com|外部链接]]
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
[[https://example.com|外部链接]]
|
||||
|
||||
**内部锚点链接:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
[[二维码]] <!-- 通过标题检索 -->
|
||||
[[npm-to]] <!-- 通过文件名检索 -->
|
||||
[[guide/markdown/math]] <!-- 通过文件路径检索-->
|
||||
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
|
||||
[[file-tree#配置]] <!-- 通过文件名检索,并链接到 heading -->
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
[[二维码]]
|
||||
|
||||
[[npm-to]]
|
||||
|
||||
[[guide/markdown/math]]
|
||||
|
||||
[[#Wiki 链接]]
|
||||
|
||||
[[file-tree#配置]]
|
||||
|
||||
[Obsidian 官方 - **Wiki Links**](https://obsidian.md/zh/help/links){.readmore}
|
||||
|
||||
## 嵌入内容
|
||||
|
||||
嵌入语法允许你将其他文件资源插入到当前页面中。
|
||||
|
||||
### 语法
|
||||
|
||||
```md
|
||||
![[文件名]]
|
||||
![[文件名#标题]]
|
||||
![[文件名#标题#子标题]]
|
||||
```
|
||||
|
||||
文件名搜索规则与 [Wiki 链接](#文件名搜索规则) 相同。
|
||||
|
||||
::: info 以 `/` 开头或 无路径前缀如 `./` 形式的,从 `public` 目录中加载资源
|
||||
:::
|
||||
|
||||
### 图片嵌入
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[image.png]]
|
||||
![[image.png|300]]
|
||||
![[image.png|300x200]]
|
||||
```
|
||||
|
||||
支持格式:`jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg]]
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
![[images/custom-hero.jpg]]
|
||||
|
||||
### PDF 嵌入
|
||||
|
||||
> [!NOTE]
|
||||
> PDF 嵌入需要启用 `markdown.pdf` 插件才能正常工作。
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[document.pdf]]
|
||||
![[document.pdf#page=1]] <!-- #page=1 表示第一页 -->
|
||||
![[document.pdf#page=1#height=300]] <!-- #height=300 表示高度为 300px -->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 音频嵌入
|
||||
|
||||
> [!note]
|
||||
> 音频嵌入需要确保文件路径正确,文件存在于文档目录中。
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
![[audio.mp3]]
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
![[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]
|
||||
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
![[video.mp4]]
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
![[https://artplayer.org/assets/sample/video.mp4]]
|
||||
|
||||
支持格式:`mp4`, `webm`, `mov` 等
|
||||
|
||||
---
|
||||
|
||||
### 内容片段嵌入
|
||||
|
||||
通过 `#标题` 可以嵌入指定标题下的内容片段:
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
![[我的笔记]]
|
||||
![[我的笔记#标题一]]
|
||||
![[我的笔记#标题一#子标题]]
|
||||
```
|
||||
|
||||
[Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore}
|
||||
[Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore}
|
||||
|
||||
## 注释
|
||||
|
||||
使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。
|
||||
|
||||
### 语法
|
||||
|
||||
**行内注释:**
|
||||
|
||||
```md
|
||||
这是一个 %%行内注释%% 示例。
|
||||
```
|
||||
|
||||
**块级注释:**
|
||||
|
||||
```md
|
||||
%%
|
||||
这是一个块级注释。
|
||||
可以跨越多行。
|
||||
%%
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
**行内注释:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
这是一个 %%行内注释%% 示例。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
这是一个 %%行内注释%% 示例。
|
||||
|
||||
---
|
||||
|
||||
**块级注释:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
注释之前的内容
|
||||
|
||||
%%
|
||||
这是一个块级注释。
|
||||
|
||||
可以跨越多行。
|
||||
%%
|
||||
|
||||
注释之后的内容
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
注释之前的内容
|
||||
|
||||
%%
|
||||
这是一个块级注释。
|
||||
|
||||
可以跨越多行。
|
||||
%%
|
||||
|
||||
注释之后的内容
|
||||
|
||||
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
|
||||
|
||||
## 配置
|
||||
|
||||
你可以在主题配置中启用或禁用这些插件:
|
||||
|
||||
```ts title=".vuepress/config.ts"
|
||||
export default defineUserConfig({
|
||||
theme: plumeTheme({
|
||||
plugins: {
|
||||
mdPower: {
|
||||
// Obsidian 兼容插件配置
|
||||
obsidian: {
|
||||
wikiLink: true, // Wiki 链接
|
||||
embedLink: true, // 嵌入内容
|
||||
comment: true, // 注释
|
||||
},
|
||||
pdf: true, // PDF 嵌入功能
|
||||
artPlayer: true, // 视频嵌入功能
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 配置项
|
||||
|
||||
:::: field-group
|
||||
|
||||
::: field name="wikiLink" type="boolean" default="true" optional
|
||||
启用 Wiki 链接语法
|
||||
:::
|
||||
|
||||
::: field name="embedLink" type="boolean" default="true" optional
|
||||
启用嵌入内容语法
|
||||
:::
|
||||
|
||||
::: field name="comment" type="boolean" default="true" optional
|
||||
启用注释语法
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
|
||||
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
|
||||
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
|
||||
- PDF 嵌入需要同时启用 `pdf` 插件
|
||||
- 视频嵌入需要同时启用 `artPlayer` 插件
|
||||
@ -2,6 +2,7 @@
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"~/themes/*": ["./.vuepress/themes/*"],
|
||||
"~/components/*": ["./.vuepress/themes/components/*"],
|
||||
|
||||
@ -10,6 +10,8 @@ export default config({
|
||||
'skills',
|
||||
'docs/snippet/code-block.snippet.md',
|
||||
'docs/snippet/whitespace.snippet.md',
|
||||
'docs/en/guide/markdown/obsidian.md',
|
||||
'docs/guide/markdown/obsidian.md',
|
||||
],
|
||||
globals: {
|
||||
__VUEPRESS_VERSION__: 'readonly',
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { commentPlugin } from '../src/node/obsidian/comment.js'
|
||||
|
||||
describe('commentPlugin', () => {
|
||||
const md = new MarkdownIt().use(commentPlugin)
|
||||
|
||||
it('should ignore inline comment', () => {
|
||||
const result = md.render('This is %%inline comment%% text.')
|
||||
expect(result).not.toContain('inline comment')
|
||||
expect(result).toContain('This is text.')
|
||||
})
|
||||
|
||||
it('should ignore block comment', () => {
|
||||
const result = md.render(`%% block comment %%
|
||||
more text`)
|
||||
expect(result).not.toContain('block comment')
|
||||
expect(result).toContain('more text')
|
||||
})
|
||||
|
||||
it('should handle multi-line block comment', () => {
|
||||
const result = md.render(`%%
|
||||
This is a block comment
|
||||
spanning multiple lines
|
||||
%%
|
||||
|
||||
This is after.`)
|
||||
expect(result).not.toContain('block comment')
|
||||
expect(result).not.toContain('spanning multiple lines')
|
||||
expect(result).toContain('This is after.')
|
||||
})
|
||||
|
||||
it('should handle comment at start of line', () => {
|
||||
const result = md.render('%%comment%% start')
|
||||
expect(result).toContain('start')
|
||||
expect(result).not.toContain('comment')
|
||||
})
|
||||
|
||||
it('should handle empty comment', () => {
|
||||
const result = md.render('%%%%')
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not treat single % as comment', () => {
|
||||
const result = md.render('50% off')
|
||||
expect(result).toContain('50%')
|
||||
expect(result).not.toContain('%%')
|
||||
})
|
||||
|
||||
it('should handle nested content after comment', () => {
|
||||
const result = md.render(`%%
|
||||
block comment
|
||||
%%
|
||||
|
||||
## Heading
|
||||
|
||||
paragraph`)
|
||||
expect(result).toContain('<h2')
|
||||
expect(result).toContain('Heading')
|
||||
expect(result).not.toContain('block comment')
|
||||
})
|
||||
|
||||
it('should not parse incomplete comment without closing', () => {
|
||||
const result = md.render('%%incomplete')
|
||||
expect(result).toContain('%%incomplete')
|
||||
})
|
||||
|
||||
it('should not parse single opening percent', () => {
|
||||
const result = md.render('% test')
|
||||
expect(result).toContain('% test')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,446 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
|
||||
|
||||
function createMockApp(pages: App['pages'] = []): App {
|
||||
return {
|
||||
pages,
|
||||
} as App
|
||||
}
|
||||
|
||||
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
|
||||
return {
|
||||
filePathRelative,
|
||||
base: '/',
|
||||
links: [],
|
||||
importedFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createMarkdownWithMockRules() {
|
||||
return MarkdownIt({ html: true }).use((md) => {
|
||||
md.block.ruler.before('code', 'import_code', () => false)
|
||||
md.renderer.rules.import_code = () => ''
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('gray-matter', () => ({
|
||||
default: vi.fn(content => ({
|
||||
content: content.replace(/^---[\s\S]*?---\n?/, ''),
|
||||
data: {},
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('embedLinkPlugin - internal markdown embed', () => {
|
||||
it('should embed entire markdown file when no heading specified', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
This is the guide content.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Step 1, Step 2, Step 3.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('<h1')
|
||||
expect(result).toContain('Introduction')
|
||||
expect(result).toContain('<h2')
|
||||
expect(result).toContain('Getting Started')
|
||||
})
|
||||
|
||||
it('should embed content under specific heading', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
This is intro.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Steps for getting started.
|
||||
|
||||
## Advanced
|
||||
|
||||
Advanced content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Steps for getting started')
|
||||
expect(result).not.toContain('Advanced content')
|
||||
})
|
||||
|
||||
it('should embed nested heading content', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Software requirements.
|
||||
|
||||
### Download
|
||||
|
||||
Download links.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Installation#Download]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Download links')
|
||||
expect(result).not.toContain('Prerequisites')
|
||||
})
|
||||
|
||||
it('should handle heading with id syntax', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction {#intro}
|
||||
|
||||
Intro content.
|
||||
|
||||
## Getting Started {#getting-started}
|
||||
|
||||
Start content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Start content')
|
||||
})
|
||||
|
||||
it('should preserve container blocks within embedded content', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
::: warning
|
||||
This is a warning block.
|
||||
:::
|
||||
|
||||
Some text.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('::: warning')
|
||||
expect(result).toContain('This is a warning block')
|
||||
})
|
||||
|
||||
it('should return empty string when heading not found', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#NonExistent]]', createMockEnv('test.md'))
|
||||
expect(result.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('should add importedFiles to env when embedding', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `# Guide\n\nContent.`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const env = createMockEnv('test.md')
|
||||
md.render('![[Guide]]', env)
|
||||
expect(env.importedFiles).toContain('docs/guide.md')
|
||||
})
|
||||
|
||||
it('should handle heading with special characters', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Guide (中文) {#zh}
|
||||
|
||||
Chinese content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Guide (中文)]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Chinese content')
|
||||
})
|
||||
|
||||
it('should handle heading with class syntax', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction {.intro .basic}
|
||||
|
||||
Intro content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Introduction]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Intro content')
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive container blocks', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/guide/',
|
||||
filePathRelative: 'docs/guide.md',
|
||||
title: 'Guide',
|
||||
content: `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Section
|
||||
|
||||
::: info
|
||||
Info block.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
Warning block.
|
||||
:::
|
||||
|
||||
More text.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Guide#Section]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Info block')
|
||||
expect(result).toContain('Warning block')
|
||||
expect(result).toContain('More text')
|
||||
})
|
||||
|
||||
it('should handle frontmatter in embedded file', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `---
|
||||
title: Actual Title
|
||||
author: Test Author
|
||||
---
|
||||
|
||||
# Actual Title
|
||||
|
||||
Page content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Page]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Page content')
|
||||
expect(result).not.toContain('author')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractContentByHeadings', () => {
|
||||
it('should return full content when no headings specified', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `# Title\n\nContent here.`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Page]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Content here')
|
||||
})
|
||||
|
||||
it('should restart heading search when encountering same text at lower level', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `---
|
||||
---
|
||||
|
||||
# Section
|
||||
|
||||
## Subsection
|
||||
|
||||
Subsection content.
|
||||
|
||||
# Section
|
||||
|
||||
## Another
|
||||
|
||||
Another content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Page#Section#Another]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Another content')
|
||||
expect(result).not.toContain('Subsection content')
|
||||
})
|
||||
|
||||
it('should reset search when encountering different heading at lower level', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `---
|
||||
---
|
||||
|
||||
# Section
|
||||
|
||||
## Subsection
|
||||
|
||||
Subsection content.
|
||||
|
||||
# Other
|
||||
|
||||
## Content
|
||||
|
||||
Other content.
|
||||
`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
// Search for "Section#Content" - after matching "Section" and failing to find "Content"
|
||||
// under "Subsection" (which is level 2 > 1), we encounter "Other" at level 1
|
||||
// heading.level (1) <= currentLevel (2), and "Other" !== "Section"
|
||||
// So we enter the else branch at lines 268-270: headingPointer = 0, currentLevel = 0
|
||||
const result = md.render('![[Page#Section#Content]]', createMockEnv('test.md'))
|
||||
expect(result.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('should extract content between sibling headings', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `# Title\n\nIntro.\n\n## Section1\n\nSection 1 content.\n\n## Section2\n\nSection 2 content.\n`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Page#Section1]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('Section 1 content')
|
||||
expect(result).not.toContain('Section 2 content')
|
||||
})
|
||||
|
||||
it('should handle deep nested headings', async () => {
|
||||
const mockPage = {
|
||||
path: '/docs/page/',
|
||||
filePathRelative: 'docs/page.md',
|
||||
title: 'Page',
|
||||
content: `# H1\n\n## H2a\n\n### H3a\n\nH3a content.\n\n### H3b\n\nH3b content.\n\n## H2b\n\nH2b content.\n`,
|
||||
markdownEnv: { base: '/' },
|
||||
}
|
||||
|
||||
const mockApp = createMockApp([mockPage] as unknown as App['pages'])
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
const result = md.render('![[Page#H2a#H3b]]', createMockEnv('test.md'))
|
||||
expect(result).toContain('H3b content')
|
||||
expect(result).not.toContain('H3a content')
|
||||
})
|
||||
})
|
||||
124
plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts
Normal file
124
plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
|
||||
|
||||
function createMockApp(pages: App['pages'] = []): App {
|
||||
return {
|
||||
pages,
|
||||
} as App
|
||||
}
|
||||
|
||||
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
|
||||
return {
|
||||
filePathRelative,
|
||||
base: '/',
|
||||
links: [],
|
||||
importedFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createMarkdownWithMockRules() {
|
||||
return MarkdownIt({ html: true }).use((md) => {
|
||||
md.block.ruler.before('code', 'import_code', () => false)
|
||||
md.renderer.rules.import_code = () => ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('embedLinkPlugin', () => {
|
||||
const mockApp = createMockApp()
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp)
|
||||
|
||||
it('should render image embed', () => {
|
||||
const result = md.render('![[image.png]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('src="/image.png"')
|
||||
expect(result).toContain('alt="image.png"')
|
||||
})
|
||||
|
||||
it('should render image with width setting', () => {
|
||||
const result = md.render('![[image.png|300]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('should render image with width x height setting', () => {
|
||||
const result = md.render('![[image.png|300x200]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 300px')
|
||||
expect(result).toContain('height: 200px')
|
||||
})
|
||||
|
||||
it('should render audio embed', () => {
|
||||
const result = md.render('![[audio.mp3]]')
|
||||
expect(result).toContain('<audio')
|
||||
expect(result).toContain('<source')
|
||||
expect(result).toContain('src="/audio.mp3"')
|
||||
})
|
||||
|
||||
it('should render video embed with artPlayer', () => {
|
||||
const result = md.render('![[video.mp4]]')
|
||||
expect(result).toContain('<ArtPlayer')
|
||||
expect(result).toContain('src="/video.mp4"')
|
||||
expect(result).toContain('type="mp4"')
|
||||
})
|
||||
|
||||
it('should render pdf embed', () => {
|
||||
const result = md.render('![[document.pdf]]')
|
||||
expect(result).toContain('<PDFViewer')
|
||||
expect(result).toContain('src="/document.pdf"')
|
||||
})
|
||||
|
||||
it('should render pdf with page hash', () => {
|
||||
const result = md.render('![[document.pdf#page=1]]')
|
||||
expect(result).toContain('page="1"')
|
||||
})
|
||||
|
||||
it('should render external http link as anchor', () => {
|
||||
const result = md.render('![[https://example.com/file]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="https://example.com/file"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('should render relative path with dot prefix', () => {
|
||||
const env = createMockEnv('docs/page.md')
|
||||
const result = md.render('![[./image.png]]', env)
|
||||
expect(result).toContain('<img')
|
||||
})
|
||||
|
||||
it('should render absolute path with slash prefix', () => {
|
||||
const result = md.render('![[/images/cover.jpg]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('src="/images/cover.jpg"')
|
||||
})
|
||||
|
||||
it('should ignore non-image with unsupported extension as link', () => {
|
||||
const result = md.render('![[file.unknown]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="file.unknown"')
|
||||
})
|
||||
|
||||
it('should not parse embed not ending with ]]', () => {
|
||||
const result = md.render('![[image.png]')
|
||||
expect(result).toContain('![[image.png]')
|
||||
})
|
||||
|
||||
it('should render markdown file embed as link when page not found', () => {
|
||||
const result = md.render('![[nonexistent.md]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/nonexistent.md"')
|
||||
})
|
||||
|
||||
it('should render markdown file embed as link when page and headings not found', () => {
|
||||
const result = md.render('![[nonexistent.md#heading1#heading2]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/nonexistent.md#heading2"')
|
||||
})
|
||||
|
||||
it('should not parse empty embed link', () => {
|
||||
const result = md.render('![[]]')
|
||||
expect(result).toContain('![[]]')
|
||||
})
|
||||
})
|
||||
75
plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts
Normal file
75
plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { App } from 'vuepress'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { obsidianPlugin } from '../src/node/obsidian/index.js'
|
||||
|
||||
function createMockApp(pages: App['pages'] = []): App {
|
||||
return {
|
||||
pages,
|
||||
} as App
|
||||
}
|
||||
|
||||
function createMarkdownWithMockRules() {
|
||||
return MarkdownIt({ html: true }).use((md) => {
|
||||
md.block.ruler.before('code', 'import_code', () => false)
|
||||
md.renderer.rules.import_code = () => ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('obsidianPlugin', () => {
|
||||
it('should enable all plugins by default', () => {
|
||||
const md = createMarkdownWithMockRules()
|
||||
const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages'])
|
||||
obsidianPlugin(md, mockApp, {})
|
||||
|
||||
const embedResult = md.render('![[image.png]]')
|
||||
expect(embedResult).toContain('<img')
|
||||
|
||||
const wikiResult = md.render('[[Home]]')
|
||||
expect(wikiResult).toContain('<VPLink')
|
||||
|
||||
const commentResult = md.render('%%comment%%')
|
||||
expect(commentResult).not.toContain('comment')
|
||||
})
|
||||
|
||||
it('should allow disabling specific plugins', () => {
|
||||
const md = createMarkdownWithMockRules()
|
||||
const mockApp = createMockApp()
|
||||
obsidianPlugin(md, mockApp, { obsidian: { wikiLink: false } })
|
||||
|
||||
const wikiResult = md.render('[[Page]]')
|
||||
expect(wikiResult).not.toContain('<VPLink')
|
||||
expect(wikiResult).toContain('[[Page]]')
|
||||
})
|
||||
|
||||
it('should disable all plugins when obsidian is false', () => {
|
||||
const md = createMarkdownWithMockRules()
|
||||
const mockApp = createMockApp()
|
||||
obsidianPlugin(md, mockApp, { obsidian: false })
|
||||
|
||||
const result = md.render('![[image.png]]')
|
||||
expect(result).not.toContain('<img')
|
||||
expect(result).toContain('![[image.png]]')
|
||||
})
|
||||
|
||||
it('should disable embedLink when explicitly set to false', () => {
|
||||
const md = createMarkdownWithMockRules()
|
||||
const mockApp = createMockApp()
|
||||
obsidianPlugin(md, mockApp, { obsidian: { embedLink: false } })
|
||||
|
||||
const result = md.render('![[image.png]]')
|
||||
expect(result).not.toContain('<img')
|
||||
})
|
||||
|
||||
it('should disable comment when explicitly set to false', () => {
|
||||
const md = createMarkdownWithMockRules()
|
||||
const mockApp = createMockApp()
|
||||
obsidianPlugin(md, mockApp, { obsidian: { comment: false } })
|
||||
|
||||
const embedResult = md.render('![[image.png]]')
|
||||
expect(embedResult).toContain('<img')
|
||||
|
||||
const commentResult = md.render('%%comment%%')
|
||||
expect(commentResult).toContain('%%comment%%')
|
||||
})
|
||||
})
|
||||
218
plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts
Normal file
218
plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findFirstPage, wikiLinkPlugin } from '../src/node/obsidian/wikiLink.js'
|
||||
|
||||
function createMockApp(pages: App['pages'] = []): App {
|
||||
return {
|
||||
pages,
|
||||
} as App
|
||||
}
|
||||
|
||||
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
|
||||
return {
|
||||
filePathRelative,
|
||||
base: '/',
|
||||
links: [],
|
||||
importedFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('wikiLinkPlugin', () => {
|
||||
const mockApp = createMockApp([
|
||||
{
|
||||
path: '/docs/getting-started/',
|
||||
filePathRelative: 'docs/getting-started/README.md',
|
||||
title: 'Getting Started',
|
||||
},
|
||||
{
|
||||
path: '/docs/guide/intro/',
|
||||
filePathRelative: 'docs/guide/intro.md',
|
||||
title: 'Introduction',
|
||||
},
|
||||
{
|
||||
path: '/api/utils/',
|
||||
filePathRelative: 'api/utils.md',
|
||||
title: 'Utils',
|
||||
},
|
||||
] as App['pages'])
|
||||
|
||||
const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin, mockApp)
|
||||
|
||||
it('should render internal wiki link to existing page', () => {
|
||||
const env = createMockEnv('docs/page.md')
|
||||
const result = md.render('[[Getting Started]]', env)
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('href="/docs/getting-started/"')
|
||||
})
|
||||
|
||||
it('should render wiki link with alias', () => {
|
||||
const env = createMockEnv('docs/page.md')
|
||||
const result = md.render('[[Getting Started|Quick Start]]', env)
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('Quick Start')
|
||||
})
|
||||
|
||||
it('should render wiki link with heading', () => {
|
||||
const env = createMockEnv('docs/page.md')
|
||||
const result = md.render('[[Introduction#Installation]]', env)
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('href="/docs/guide/intro/#installation"')
|
||||
})
|
||||
|
||||
it('should render wiki link with heading and alias', () => {
|
||||
const env = createMockEnv('docs/page.md')
|
||||
const result = md.render('[[Introduction#Installation|Install Guide]]', env)
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('Install Guide')
|
||||
expect(result).toContain('href="/docs/guide/intro/#installation"')
|
||||
})
|
||||
|
||||
it('should render external http link', () => {
|
||||
const result = md.render('[[https://example.com]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="https://example.com"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('should render external link with alias', () => {
|
||||
const result = md.render('[[https://example.com|Example Site]]')
|
||||
expect(result).toContain('>Example Site<')
|
||||
expect(result).toContain('href="https://example.com"')
|
||||
})
|
||||
|
||||
it('should render external link with heading and alias', () => {
|
||||
const result = md.render('[[https://example.com/page#section|Go to Section]]')
|
||||
expect(result).toContain('>Go to Section<')
|
||||
expect(result).toContain('href="https://example.com/page#section"')
|
||||
})
|
||||
|
||||
it('should render external link with heading but no alias', () => {
|
||||
const result = md.render('[[https://example.com/page#section]]')
|
||||
expect(result).toContain('href="https://example.com/page#section"')
|
||||
expect(result).toContain('https://example.com/page > 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/')
|
||||
})
|
||||
})
|
||||
@ -97,6 +97,7 @@
|
||||
"@vuepress/helper": "catalog:vuepress",
|
||||
"@vueuse/core": "catalog:prod",
|
||||
"chokidar": "catalog:prod",
|
||||
"gray-matter": "catalog:prod",
|
||||
"image-size": "catalog:prod",
|
||||
"local-pkg": "catalog:prod",
|
||||
"lru-cache": "catalog:prod",
|
||||
|
||||
@ -18,7 +18,7 @@ const installed = {
|
||||
mpegtsjs: isPackageExists('mpegts.js'),
|
||||
}
|
||||
|
||||
const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv']
|
||||
export const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv', 'mkv', 'mov', 'ogv']
|
||||
|
||||
export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
|
||||
createEmbedRuleBlock<ArtPlayerTokenMeta>(md, {
|
||||
@ -51,7 +51,7 @@ export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
|
||||
})
|
||||
}
|
||||
|
||||
function checkSupportType(type?: string) {
|
||||
export function checkSupportType(type?: string) {
|
||||
if (!type)
|
||||
return
|
||||
|
||||
|
||||
9
plugins/plugin-md-power/src/node/obsidian/README.md
Normal file
9
plugins/plugin-md-power/src/node/obsidian/README.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 说明
|
||||
|
||||
兼容部分 obsidian 的 markdown 扩展语法。
|
||||
|
||||
**仅计划支持 obsidian 的官方扩展语法**。
|
||||
|
||||
- [x] wikiLink: `[[文件名]]` `[[文件名#标题]]` `[[文件名#标题#标题]]` `[[文件名#标题|别名]]`
|
||||
- [x] embedLink: `![[文件名]]` `![[文件名#标题]]` `![[文件名#标题#标题]]`
|
||||
- [x] comment: `%%注释%%`
|
||||
117
plugins/plugin-md-power/src/node/obsidian/comment.ts
Normal file
117
plugins/plugin-md-power/src/node/obsidian/comment.ts
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* comment 是 obsidian 提供的注释语法。使用 `%%` 包裹文本来添加注释, 注释仅在编辑模式中可见。
|
||||
* 在此兼容实现中,被 `%%` 包裹的内容,将会直接被忽略,不渲染到页面中。
|
||||
*
|
||||
* ```markdown
|
||||
* 这是一个 %%行内%% 注释。
|
||||
*
|
||||
* %%
|
||||
* 这是一个块级注释
|
||||
* 可以跨越多行
|
||||
* %%
|
||||
* ```
|
||||
*
|
||||
* @see - https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A
|
||||
*/
|
||||
|
||||
import type { Markdown } from 'vuepress/markdown'
|
||||
|
||||
export function commentPlugin(md: Markdown): void {
|
||||
md.inline.ruler.before(
|
||||
'html_inline',
|
||||
'obsidian_inline_comment',
|
||||
(state, silent) => {
|
||||
let found = false
|
||||
const max = state.posMax
|
||||
const start = state.pos
|
||||
if (
|
||||
state.src.charCodeAt(start) !== 0x25 // %
|
||||
|| state.src.charCodeAt(start + 1) !== 0x25 // %
|
||||
) {
|
||||
return false
|
||||
}
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return false
|
||||
|
||||
// - %%%%
|
||||
if (max - start < 5)
|
||||
return false
|
||||
|
||||
state.pos = start + 2
|
||||
|
||||
// 查找 %%
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x25
|
||||
&& state.src.charCodeAt(state.pos + 1) === 0x25) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state)
|
||||
}
|
||||
|
||||
if (!found || start + 2 === state.pos) {
|
||||
state.pos = start
|
||||
return false
|
||||
}
|
||||
// found!
|
||||
state.posMax = state.pos
|
||||
state.pos = start + 2
|
||||
|
||||
const token = state.push('obsidian_inline_comment', '', 0)
|
||||
token.content = state.src.slice(start + 2, state.pos)
|
||||
token.markup = '%%'
|
||||
token.map = [start, state.pos + 2]
|
||||
|
||||
state.pos = state.posMax + 2
|
||||
state.posMax = max
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
md.block.ruler.before(
|
||||
'html_block',
|
||||
'obsidian_block_comment',
|
||||
(state, startLine, endLine, silent) => {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine]
|
||||
// check starts with %%
|
||||
if (state.src.charCodeAt(start) !== 0x25 // %
|
||||
|| state.src.charCodeAt(start + 1) !== 0x25 // %
|
||||
) {
|
||||
return false
|
||||
}
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return true
|
||||
|
||||
let line = startLine
|
||||
let content = ''
|
||||
let found = false
|
||||
// 查找 %%
|
||||
while (++line < endLine) {
|
||||
if (state.src.slice(state.bMarks[line], state.eMarks[line]).trim() === '%%') {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
content += `${state.src.slice(state.bMarks[line], state.eMarks[line])}\n`
|
||||
}
|
||||
|
||||
if (!found)
|
||||
return false
|
||||
|
||||
const token = state.push('obsidian_block_comment', '', 0)
|
||||
token.content = content
|
||||
token.markup = '%%'
|
||||
token.map = [startLine, line + 1]
|
||||
|
||||
state.line = line + 1
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
md.renderer.rules.obsidian_inline_comment = () => ''
|
||||
md.renderer.rules.obsidian_block_comment = () => ''
|
||||
}
|
||||
301
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
301
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Embed Link 是属于 obsidian 官方扩展的 markdown 语法
|
||||
*
|
||||
* - ![[文件名]] ![[文件名#标题]] ![[文件名#标题#标题]]
|
||||
* - ![[资源链接]]:
|
||||
* - ![[图片]] ![[图片|width]] ![[图片|widthxheight]]
|
||||
* - ![[pdf]] ![[pdf#page=1#height=300]]
|
||||
* - ![[音频]]
|
||||
* - ![[视频]]
|
||||
*
|
||||
* @see - https://obsidian.md/zh/help/embeds
|
||||
* @see - https://obsidian.md/zh/help/file-formats
|
||||
*
|
||||
* 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。
|
||||
*/
|
||||
|
||||
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
|
||||
import type { App } from 'vuepress'
|
||||
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||
import grayMatter from 'gray-matter'
|
||||
import Token from 'markdown-it/lib/token.mjs'
|
||||
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||
import { hash, path } from 'vuepress/utils'
|
||||
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
|
||||
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
||||
import { parseRect } from '../utils/parseRect.js'
|
||||
import { slugify } from '../utils/slugify.js'
|
||||
import { findFirstPage } from './wikiLink.js'
|
||||
|
||||
interface EmbedLinkMeta {
|
||||
filename: string
|
||||
hashes: string[]
|
||||
settings: string
|
||||
}
|
||||
|
||||
const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm']
|
||||
const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc']
|
||||
const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`)
|
||||
|
||||
const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine]
|
||||
const max = state.eMarks[startLine]
|
||||
|
||||
// - ![[]]
|
||||
if (max - start < 6)
|
||||
return false
|
||||
|
||||
// 是否以 `![[` 开头
|
||||
if (
|
||||
state.src.charCodeAt(start) !== 0x21 // \!
|
||||
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|
||||
|| state.src.charCodeAt(start + 2) !== 0x5B // [
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const line = state.src.slice(start, max).trim()
|
||||
// 是否以 `]]` 结尾
|
||||
if (
|
||||
line.charCodeAt(line.length - 1) !== 0x5D // ]
|
||||
|| line.charCodeAt(line.length - 2) !== 0x5D // ]
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return true
|
||||
|
||||
// ![[xxxx]]
|
||||
// ^^^^ <- content
|
||||
const content = line.slice(3, -2).trim()
|
||||
|
||||
const [file, settings] = content.split('|').map(x => x.trim())
|
||||
const [filename, ...hashes] = file.trim().split('#').map(x => x.trim())
|
||||
const extname = path.extname(filename).toLowerCase()
|
||||
|
||||
// 渲染为 图片
|
||||
if (EXTENSION_IMAGES.includes(extname)) {
|
||||
const token = state.push('image', 'img', 1)
|
||||
token.content = filename
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('alt', filename)
|
||||
if (settings) {
|
||||
const [width, height] = settings.split('x').map(x => x.trim())
|
||||
const styles: string[] = []
|
||||
if (width)
|
||||
styles.push(`width: ${parseRect(width)}`)
|
||||
if (height)
|
||||
styles.push(`height: ${parseRect(height)}`)
|
||||
token.attrSet('style', styles.join(';'))
|
||||
}
|
||||
const text = new Token('text', '', 0)
|
||||
text.content = filename
|
||||
token.children = [text]
|
||||
}
|
||||
// 渲染为音频
|
||||
else if (EXTENSION_AUDIOS.includes(extname)) {
|
||||
const token = state.push('audio_open', 'audio', 1)
|
||||
token.content = filename
|
||||
token.attrSet('controls', 'true')
|
||||
token.attrSet('preload', 'metadata')
|
||||
token.attrSet('aria-label', filename)
|
||||
const sourceToken = state.push('source_open', 'source', 1)
|
||||
sourceToken.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
state.push('audio_close', 'audio', -1)
|
||||
}
|
||||
// 渲染为视频,使用 ArtPlayer
|
||||
else if (EXTENSION_VIDEOS.includes(extname)) {
|
||||
const token = state.push('video_artPlayer_open', 'ArtPlayer', 1)
|
||||
const type = extname.slice(1)
|
||||
checkSupportType(type)
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('type', type)
|
||||
token.attrSet('width', '100%')
|
||||
token.attrSet(':fullscreen', 'true')
|
||||
token.attrSet(':flip', 'true')
|
||||
token.attrSet(':playback-rate', 'true')
|
||||
token.attrSet(':aspect-ratio', 'true')
|
||||
token.attrSet(':setting', 'true')
|
||||
token.attrSet(':pip', 'true')
|
||||
token.attrSet(':volume', '0.75')
|
||||
token.content = filename
|
||||
state.push('video_artPlayer_close', 'ArtPlayer', -1)
|
||||
}
|
||||
// 渲染为 pdf
|
||||
else if (extname === '.pdf') {
|
||||
const token = state.push('pdf_open', 'PDFViewer', 1)
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('width', '100%')
|
||||
for (const hash of hashes) {
|
||||
const [key, value] = hash.split('=').map(x => x.trim())
|
||||
token.attrSet(key, value)
|
||||
}
|
||||
token.content = filename
|
||||
state.push('pdf_close', 'PDFViewer', -1)
|
||||
}
|
||||
// 非受支持的外部资源,渲染为链接
|
||||
else if (isLinkHttp(filename) || (extname && extname !== '.md')) {
|
||||
const token = state.push('link_open', 'a', 1)
|
||||
token.attrSet('href', filename)
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noopener noreferrer')
|
||||
token.content = filename
|
||||
const content = state.push('text', '', 0)
|
||||
content.content = filename
|
||||
state.push('link_close', 'a', -1)
|
||||
}
|
||||
// 剩余情况,如内部的 markdown 文件
|
||||
// 在 obsidian_embed_link renderer rule 中处理
|
||||
else {
|
||||
const token = state.push('obsidian_embed_link', '', 0)
|
||||
token.markup = '![[]]'
|
||||
token.meta = {
|
||||
filename: filename.trim(),
|
||||
hashes: hashes.map(hash => hash.trim()),
|
||||
settings: settings?.trim(),
|
||||
} as EmbedLinkMeta
|
||||
token.content = content
|
||||
}
|
||||
|
||||
state.line = startLine + 1
|
||||
return true
|
||||
}
|
||||
|
||||
export function embedLinkPlugin(md: Markdown, app: App): void {
|
||||
md.block.ruler.before(
|
||||
'import_code',
|
||||
'obsidian_embed_link',
|
||||
embedLinkDef,
|
||||
{ alt: ['paragraph', 'reference', 'blockquote', 'list'] },
|
||||
)
|
||||
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||
const token = tokens[idx]
|
||||
const { filename, hashes, settings } = token.meta as EmbedLinkMeta
|
||||
const internalPage = findFirstPage(app, filename, env.filePathRelative ?? '')
|
||||
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
|
||||
if (internalPage) {
|
||||
const { content: rawContent } = grayMatter(internalPage.content)
|
||||
const content = extractContentByHeadings(rawContent, hashes)
|
||||
internalPage.filePathRelative && (env.importedFiles ??= []).push(internalPage.filePathRelative)
|
||||
return md.render(content, cleanMarkdownEnv(internalPage.markdownEnv))
|
||||
}
|
||||
|
||||
// 其他资源,解析为链接
|
||||
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
|
||||
const anchor = hashes.at(-1)
|
||||
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||
const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : ''))
|
||||
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||
md.utils.escapeHtml(text)
|
||||
}</a>`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFilenameToAssetPath(filename: string): string {
|
||||
if (isLinkHttp(filename) || filename[0] === '.' || filename[0] === '/') {
|
||||
return filename
|
||||
}
|
||||
return `/${filename}`
|
||||
}
|
||||
|
||||
interface ParsedHeading {
|
||||
lineIndex: number
|
||||
level: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// 支持: ## 标题 {#id .class key=value} 或 ## 标题 {#id}
|
||||
const HEADING_HASH_REG = /^#+/
|
||||
const HEADING_ATTRS_REG = /(?:\{[^}]*\})?$/
|
||||
|
||||
function extractContentByHeadings(content: string, headings: string[]): string {
|
||||
if (!headings.length)
|
||||
return content
|
||||
|
||||
const containers: Record<string, string> = {}
|
||||
|
||||
content = content.replaceAll(/(?<mark>:{3,})[\s\S]*?\k<mark>/g, (matched) => {
|
||||
const key = hash(matched)
|
||||
containers[key] = matched
|
||||
return `<!--container:${key}-->`
|
||||
})
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
const allHeadings: ParsedHeading[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let text = lines[i].trimEnd()
|
||||
let level = 0
|
||||
text = text.replace(HEADING_HASH_REG, (matched) => {
|
||||
level = matched.length
|
||||
return ''
|
||||
})
|
||||
if (level) {
|
||||
text = text.replace(HEADING_ATTRS_REG, '').trim()
|
||||
allHeadings.push({ lineIndex: i, level, text })
|
||||
}
|
||||
}
|
||||
|
||||
// 查找匹配的标题序列(逻辑同上)
|
||||
let targetHeadingIndex = -1
|
||||
let currentLevel = 0
|
||||
let headingPointer = 0
|
||||
|
||||
for (let i = 0; i < allHeadings.length; i++) {
|
||||
const heading = allHeadings[i]
|
||||
|
||||
if (headingPointer === 0) {
|
||||
if (heading.text === headings[0]) {
|
||||
headingPointer++
|
||||
currentLevel = heading.level
|
||||
if (headingPointer === headings.length) {
|
||||
targetHeadingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (heading.level > currentLevel && heading.text === headings[headingPointer]) {
|
||||
headingPointer++
|
||||
currentLevel = heading.level
|
||||
if (headingPointer === headings.length) {
|
||||
targetHeadingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
else if (heading.level <= currentLevel) {
|
||||
if (heading.text === headings[0]) {
|
||||
headingPointer = 1
|
||||
currentLevel = heading.level
|
||||
}
|
||||
else {
|
||||
headingPointer = 0
|
||||
currentLevel = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetHeadingIndex === -1) {
|
||||
console.warn(`No heading found for ${headings.join(' > ')}`)
|
||||
return ''
|
||||
}
|
||||
|
||||
const targetHeading = allHeadings[targetHeadingIndex]
|
||||
const startLine = targetHeading.lineIndex + 1
|
||||
const targetLevel = targetHeading.level
|
||||
|
||||
let endLine = lines.length
|
||||
for (let i = targetHeadingIndex + 1; i < allHeadings.length; i++) {
|
||||
if (allHeadings[i].level <= targetLevel) {
|
||||
endLine = allHeadings[i].lineIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const result = lines.slice(startLine, endLine).join('\n').trim()
|
||||
|
||||
return result.replaceAll(/<!--container:(.*?)-->/g, (_, key) => containers[key] ?? '')
|
||||
}
|
||||
27
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
27
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { Markdown } from 'vuepress/markdown'
|
||||
import type { MarkdownPowerPluginOptions } from '../../shared/index.js'
|
||||
import { isPlainObject } from 'vuepress/shared'
|
||||
import { commentPlugin } from './comment.js'
|
||||
import { embedLinkPlugin } from './embedLink.js'
|
||||
import { wikiLinkPlugin } from './wikiLink.js'
|
||||
|
||||
export function obsidianPlugin(
|
||||
md: Markdown,
|
||||
app: App,
|
||||
options: MarkdownPowerPluginOptions,
|
||||
) {
|
||||
if (options.obsidian === false)
|
||||
return
|
||||
|
||||
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
|
||||
|
||||
if (obsidian.wikiLink !== false)
|
||||
wikiLinkPlugin(md, app)
|
||||
|
||||
if (obsidian.embedLink !== false)
|
||||
embedLinkPlugin(md, app)
|
||||
|
||||
if (obsidian.comment !== false)
|
||||
commentPlugin(md)
|
||||
}
|
||||
155
plugins/plugin-md-power/src/node/obsidian/wikiLink.ts
Normal file
155
plugins/plugin-md-power/src/node/obsidian/wikiLink.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Wiki Link 是属于 obsidian 官方扩展的 markdown 语法
|
||||
*
|
||||
* [[文件名]] [[文件名#标题]] [[文件名#标题#标题]] [[文件名#标题|别名]]
|
||||
*
|
||||
* @see - https://obsidian.md/zh/help/links
|
||||
*
|
||||
* 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。
|
||||
*/
|
||||
|
||||
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
|
||||
import type { App } from 'vuepress'
|
||||
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||
import { sortBy } from '@pengzhanbo/utils'
|
||||
import { ensureLeadingSlash, isLinkHttp, removeLeadingSlash } from 'vuepress/shared'
|
||||
import { path } from 'vuepress/utils'
|
||||
import { resolvePaths } from '../enhance/links.js'
|
||||
import { slugify } from '../utils/slugify.js'
|
||||
|
||||
interface WikiLinkMeta {
|
||||
filename: string
|
||||
alias: string
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
const wikiLinkDef: RuleInline = (state, silent) => {
|
||||
let found = false
|
||||
const max = state.posMax
|
||||
const start = state.pos
|
||||
|
||||
if (
|
||||
state.src.charCodeAt(start) !== 0x5B
|
||||
|| state.src.charCodeAt(start + 1) !== 0x5B
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return false
|
||||
|
||||
// - [[]]
|
||||
if (max - start < 5)
|
||||
return false
|
||||
|
||||
state.pos = start + 2
|
||||
|
||||
// 查找 ]]
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5D
|
||||
&& state.src.charCodeAt(state.pos + 1) === 0x5D) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state)
|
||||
}
|
||||
|
||||
if (!found || start + 2 === state.pos) {
|
||||
state.pos = start
|
||||
return false
|
||||
}
|
||||
// [[xxxx]]
|
||||
// ^^^^ <- content
|
||||
const content = state.src.slice(start + 2, state.pos).trim()
|
||||
// found!
|
||||
state.posMax = state.pos
|
||||
state.pos = start + 2
|
||||
|
||||
const [file, alias] = content.split('|')
|
||||
const [filename, ...titles] = file.trim().split('#')
|
||||
|
||||
const token = state.push('obsidian_wiki_link', '', 0)
|
||||
token.markup = '[[]]'
|
||||
token.meta = {
|
||||
filename: filename.trim(),
|
||||
titles: titles.map(title => title.trim()),
|
||||
alias: alias?.trim(),
|
||||
} as WikiLinkMeta
|
||||
token.content = content
|
||||
|
||||
state.pos = state.posMax + 2
|
||||
state.posMax = max
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function wikiLinkPlugin(md: Markdown, app: App) {
|
||||
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
|
||||
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||
const token = tokens[idx]
|
||||
const { filename, titles, alias } = token.meta as WikiLinkMeta
|
||||
const anchor = titles.at(-1)
|
||||
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||
// external link
|
||||
if (isLinkHttp(filename)) {
|
||||
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
|
||||
return `<a href="${filename}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||
md.utils.escapeHtml(text)
|
||||
}</a>`
|
||||
}
|
||||
// internal hash link
|
||||
if (!filename) { // internal page hash link
|
||||
return `<VPLink href="${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||
}
|
||||
const internal = findFirstPage(app, filename, env.filePathRelative ?? '')
|
||||
if (internal) {
|
||||
const { absolutePath, relativePath } = resolvePaths(
|
||||
internal.filePathRelative!,
|
||||
env.base || '/',
|
||||
env.filePathRelative ?? null,
|
||||
)
|
||||
;(env.links ??= []).push({
|
||||
raw: internal.filePathRelative!,
|
||||
absolute: absolutePath,
|
||||
relative: relativePath,
|
||||
})
|
||||
return `<VPLink href="${internal.path}${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||
}
|
||||
|
||||
// other asset url
|
||||
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
|
||||
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
|
||||
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||
md.utils.escapeHtml(text)
|
||||
}</a>`
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +13,7 @@ import { linksPlugin } from './enhance/links.js'
|
||||
import { iconPlugin } from './icon/index.js'
|
||||
import { inlineSyntaxPlugin } from './inline/index.js'
|
||||
import { LOCALE_OPTIONS } from './locales/index.js'
|
||||
import { obsidianPlugin } from './obsidian/index.js'
|
||||
import { prepareConfigFile } from './prepareConfigFile.js'
|
||||
import { provideData } from './provideData.js'
|
||||
|
||||
@ -105,6 +106,7 @@ export function markdownPowerPlugin(
|
||||
embedSyntaxPlugin(md, options)
|
||||
inlineSyntaxPlugin(md, options)
|
||||
iconPlugin(md, options.icon ?? (isPlainObject(options.icons) ? options.icons : {}))
|
||||
obsidianPlugin(md, app, options)
|
||||
|
||||
if (options.demo)
|
||||
demoPlugin(app, md)
|
||||
|
||||
26
plugins/plugin-md-power/src/node/utils/slugify.ts
Normal file
26
plugins/plugin-md-power/src/node/utils/slugify.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const rControl = /[\u0000-\u001F]/g
|
||||
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g
|
||||
const rCombining = /[\u0300-\u036F]/g
|
||||
|
||||
/**
|
||||
* Default slugification function
|
||||
*/
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.normalize('NFKD')
|
||||
// Remove accents
|
||||
.replace(rCombining, '')
|
||||
// Remove control characters
|
||||
.replace(rControl, '')
|
||||
// Replace special characters
|
||||
.replace(rSpecial, '-')
|
||||
// Remove continuos separators
|
||||
.replace(/-{2,}/g, '-')
|
||||
// Remove prefixing and trailing separators
|
||||
.replace(/^-+|-+$/g, '')
|
||||
// ensure it doesn't start with a number
|
||||
.replace(/^(\d)/, '_$1')
|
||||
// lowercase
|
||||
.toLowerCase()
|
||||
}
|
||||
5
plugins/plugin-md-power/src/shared/obsidian.ts
Normal file
5
plugins/plugin-md-power/src/shared/obsidian.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface ObsidianOptions {
|
||||
wikiLink?: boolean
|
||||
embedLink?: boolean
|
||||
comment?: boolean
|
||||
}
|
||||
@ -18,7 +18,7 @@ export interface PDFTokenMeta extends SizeOptions {
|
||||
*
|
||||
* 要显示的页码
|
||||
*/
|
||||
page?: number
|
||||
page?: number | string
|
||||
/**
|
||||
* Whether to hide toolbar
|
||||
*
|
||||
|
||||
@ -9,6 +9,7 @@ import type { IconOptions } from './icon.js'
|
||||
import type { MDPowerLocaleData } from './locale.js'
|
||||
import type { MarkOptions } from './mark.js'
|
||||
import type { NpmToOptions } from './npmTo.js'
|
||||
import type { ObsidianOptions } from './obsidian.js'
|
||||
import type { PDFOptions } from './pdf.js'
|
||||
import type { PlotOptions } from './plot.js'
|
||||
import type { ReplOptions } from './repl.js'
|
||||
@ -406,5 +407,12 @@ export interface MarkdownPowerPluginOptions {
|
||||
*/
|
||||
imageSize?: boolean | 'local' | 'all'
|
||||
|
||||
/**
|
||||
* 是否启用 obsidian 官方 markdown 扩展语法的兼容性支持
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
obsidian?: boolean | ObsidianOptions
|
||||
|
||||
locales?: LocaleConfig<MDPowerLocaleData>
|
||||
}
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -711,6 +711,9 @@ importers:
|
||||
esbuild:
|
||||
specifier: ^0.28.0
|
||||
version: 0.28.0
|
||||
gray-matter:
|
||||
specifier: catalog:prod
|
||||
version: 4.0.3
|
||||
image-size:
|
||||
specifier: catalog:prod
|
||||
version: 2.0.2
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRouter, withBase } from 'vuepress/client'
|
||||
import { useData, useLink } from '../composables/index.js'
|
||||
import { resolveNavLink } from '../utils/index.js'
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string
|
||||
@ -19,6 +20,13 @@ const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))
|
||||
|
||||
const { link, isExternal, isExternalProtocol } = useLink(toRef(props, 'href'), toRef(props, 'target'))
|
||||
|
||||
const resolvedText = computed(() => {
|
||||
if (props.text || isExternal.value || !link.value)
|
||||
return props.text
|
||||
const { text } = resolveNavLink(link.value)
|
||||
return text
|
||||
})
|
||||
|
||||
function linkTo(e: Event) {
|
||||
if (!isExternal.value && link.value) {
|
||||
e.preventDefault()
|
||||
@ -36,9 +44,8 @@ function linkTo(e: Event) {
|
||||
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
|
||||
@click="linkTo($event)"
|
||||
>
|
||||
<slot>
|
||||
{{ text || href }}
|
||||
</slot>
|
||||
<slot>{{ resolvedText || href }}</slot>
|
||||
<slot name="after-text" />
|
||||
<span v-if="isExternal && !noIcon" class="visually-hidden">
|
||||
{{ theme.openNewWindowText || '(Open in new window)' }}
|
||||
</span>
|
||||
|
||||
@ -56,7 +56,7 @@ export function useLink(
|
||||
const maybeIsExternal = computed(() => {
|
||||
const link = toValue(href)
|
||||
const rawTarget = toValue(target)
|
||||
if (!link)
|
||||
if (!link || link[0] === '#')
|
||||
return false
|
||||
if (rawTarget === '_blank' || isLinkExternal(link))
|
||||
return true
|
||||
@ -70,8 +70,12 @@ export function useLink(
|
||||
if (!link || maybeIsExternal.value)
|
||||
return link
|
||||
|
||||
if (link[0] === '#')
|
||||
return page.value.path + link
|
||||
|
||||
const currentPath = page.value.filePathRelative ? `/${page.value.filePathRelative}` : undefined
|
||||
const path = resolveRouteFullPath(link, currentPath)
|
||||
|
||||
if (path.includes('#')) {
|
||||
// Compare path + anchor with current route path
|
||||
// Convert to anchor link to avoid page refresh
|
||||
|
||||
@ -189,6 +189,12 @@ const copyPageText = computed(() => {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.vp-page-context-menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-context-button {
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
|
||||
@ -67,6 +67,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
|
||||
'youtube',
|
||||
'qrcode',
|
||||
'encrypt',
|
||||
'obsidian',
|
||||
'locales',
|
||||
]
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
|
||||
"@theme/*": ["./theme/src/client/components/*"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user