Compare commits
15 Commits
15f51dd303
...
3b7495e553
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b7495e553 | |||
|
|
02038f2df0 | ||
|
|
e5126663ef | ||
|
|
402f259086 | ||
|
|
58ea2fc8cb | ||
|
|
6ebb1bda6e | ||
|
|
68f39695c4 | ||
|
|
76787f6530 | ||
|
|
e2b47da532 | ||
|
|
035d521e96 | ||
|
|
bfd0c8409c | ||
|
|
e11c7a8fcd | ||
|
|
1329051536 | ||
|
|
0677f6749e | ||
|
|
28963eb419 |
23
CHANGELOG.md
23
CHANGELOG.md
@ -1,3 +1,26 @@
|
|||||||
|
# [1.0.0-rc.196](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.195...v1.0.0-rc.196) (2026-04-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* fix security ([e512666](https://github.com/pengzhanbo/vuepress-theme-plume/commit/e5126663ef50b4f720424412b38ad3b22a7178da))
|
||||||
|
* **plugin-md-power:** fix cell display issue caused by colspan in table ([#891](https://github.com/pengzhanbo/vuepress-theme-plume/issues/891)) ([6ebb1bd](https://github.com/pengzhanbo/vuepress-theme-plume/commit/6ebb1bda6e4ef379e156e7299c149e10c472d705))
|
||||||
|
* **theme:** remove `cwd` options from `picomatch` ([#892](https://github.com/pengzhanbo/vuepress-theme-plume/issues/892)) ([58ea2fc](https://github.com/pengzhanbo/vuepress-theme-plume/commit/58ea2fc8cbcef0cb968997ad6dead2f4960d465a))
|
||||||
|
|
||||||
|
# [1.0.0-rc.195](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.194...v1.0.0-rc.195) (2026-04-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **plugin-md-power:** compat obsidian official markdown syntax ([#890](https://github.com/pengzhanbo/vuepress-theme-plume/issues/890)) ([bfd0c84](https://github.com/pengzhanbo/vuepress-theme-plume/commit/bfd0c8409c4c55c82f6c55d681f6b3eaafeaddfb))
|
||||||
|
|
||||||
|
# [1.0.0-rc.194](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.193...v1.0.0-rc.194) (2026-04-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **plugin-search:** fix search index race condition on pageUpdated, close [#888](https://github.com/pengzhanbo/vuepress-theme-plume/issues/888) ([#889](https://github.com/pengzhanbo/vuepress-theme-plume/issues/889)) ([28963eb](https://github.com/pengzhanbo/vuepress-theme-plume/commit/28963eb419e9a1707157929fe546dc44d1ef771e))
|
||||||
|
|
||||||
# [1.0.0-rc.193](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.193...v1.0.0-rc.192) (2026-04-02)
|
# [1.0.0-rc.193](https://github.com/pengzhanbo/vuepress-theme-plume/compare/v1.0.0-rc.193...v1.0.0-rc.192) (2026-04-02)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "create-vuepress-theme-plume",
|
"name": "create-vuepress-theme-plume",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.193",
|
"version": "1.0.0-rc.196",
|
||||||
"description": "The cli for create vuepress-theme-plume's project",
|
"description": "The cli for create vuepress-theme-plume's project",
|
||||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -40,8 +40,8 @@
|
|||||||
"sort-package-json": "catalog:prod"
|
"sort-package-json": "catalog:prod"
|
||||||
},
|
},
|
||||||
"plume-deps": {
|
"plume-deps": {
|
||||||
"vuepress": "2.0.0-rc.26",
|
"vuepress": "2.0.0-rc.28",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.32",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
|||||||
'chat',
|
'chat',
|
||||||
'include',
|
'include',
|
||||||
'env',
|
'env',
|
||||||
|
'obsidian',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
|||||||
'chat',
|
'chat',
|
||||||
'include',
|
'include',
|
||||||
'env',
|
'env',
|
||||||
|
'obsidian',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -58,6 +58,7 @@ export const theme: Theme = plumeTheme({
|
|||||||
jsfiddle: true,
|
jsfiddle: true,
|
||||||
demo: true,
|
demo: true,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
|
obsidian: true,
|
||||||
npmTo: ['pnpm', 'yarn', 'npm'],
|
npmTo: ['pnpm', 'yarn', 'npm'],
|
||||||
repl: {
|
repl: {
|
||||||
go: true,
|
go: true,
|
||||||
|
|||||||
337
docs/en/guide/markdown/obsidian.md
Normal file
337
docs/en/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
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 used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
```md
|
||||||
|
[[filename]]
|
||||||
|
[[filename#heading]]
|
||||||
|
[[filename#heading#subheading]]
|
||||||
|
[[filename|alias]]
|
||||||
|
[[filename#heading|alias]]
|
||||||
|
[[https://example.com|External Link]]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filename Search Rules
|
||||||
|
|
||||||
|
When using Wiki Links, filenames are matched according to the following rules:
|
||||||
|
|
||||||
|
**Match Priority:**
|
||||||
|
|
||||||
|
1. **Full Path** - Exact match against file paths
|
||||||
|
2. **Fuzzy Match** - Match filenames at the end of paths, prioritizing the shortest path
|
||||||
|
|
||||||
|
**Path Resolution Rules:**
|
||||||
|
|
||||||
|
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
|
||||||
|
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, prioritizing the shortest path
|
||||||
|
- **Directory form** (ending with `/`): Matches `README.md` in that directory
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
Assuming the following document structure:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/
|
||||||
|
├── README.md
|
||||||
|
├── guide/
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── markdown/
|
||||||
|
│ └── obsidian.md
|
||||||
|
```
|
||||||
|
|
||||||
|
In `docs/guide/markdown/obsidian.md`:
|
||||||
|
|
||||||
|
| Syntax | Match Result |
|
||||||
|
| ------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
|
| `[[obsidian]]` | Matches `docs/guide/markdown/obsidian.md` (matched via filename) |
|
||||||
|
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
|
||||||
|
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
|
||||||
|
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**External Links:**
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
[[https://example.com|External Link]]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
[[https://example.com|External Link]]
|
||||||
|
|
||||||
|
**Internal Anchor Links:**
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
[[npm-to]] <!-- Search by filename -->
|
||||||
|
[[guide/markdown/math]] <!-- Search by file path -->
|
||||||
|
[[#Wiki Links]] <!-- Heading on current page -->
|
||||||
|
[[file-tree#Configuration]] <!-- Search by filename, link to heading -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
[[npm-to]]
|
||||||
|
|
||||||
|
[[guide/markdown/math]]
|
||||||
|
|
||||||
|
[[#Wiki Links]]
|
||||||
|
|
||||||
|
[[file-tree#Configuration]]
|
||||||
|
|
||||||
|
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
|
||||||
|
|
||||||
|
## Embeds
|
||||||
|
|
||||||
|
The embed syntax allows you to insert other file resources into the current page.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[filename]]
|
||||||
|
![[filename#heading]]
|
||||||
|
![[filename#heading#subheading]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Filename search rules are the same as [Wiki Links](#filename-search-rules).
|
||||||
|
|
||||||
|
::: info Resources starting with `/` or having no path prefix like `./` are loaded from the `public` directory
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Image Embeds
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[image]]
|
||||||
|
![[image|300]]
|
||||||
|
![[image|300x200]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
::: demo markdown title="Basic Image" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: demo markdown title="Set Width" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg|300]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: demo markdown title="Set Width and Height" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg|300x200]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### PDF Embeds
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> PDF embeds require the `markdown.pdf` plugin to be enabled for proper functionality.
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[document.pdf]]
|
||||||
|
![[document.pdf#page=1]] <!-- #page=1 means first page -->
|
||||||
|
![[document.pdf#page=1#height=300]] <!-- #page=page number #height=height -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats: `pdf`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Audio Embeds
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[audio file]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Video Embeds
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
|
||||||
|
|
||||||
|
**Syntax:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[video file]]
|
||||||
|
![[video file#height=400]] <!-- Set video height -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats: `mp4`, `webm`, `mov`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Content Fragment Embeds
|
||||||
|
|
||||||
|
Content fragments under a specified heading can be embedded using `#heading`:
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[my-note]]
|
||||||
|
![[my-note#Heading One]]
|
||||||
|
![[my-note#Heading One#Subheading]]
|
||||||
|
```
|
||||||
|
|
||||||
|
[Obsidian Official - **Insert Files**](https://obsidian.md/en/help/embeds){.readmore}
|
||||||
|
[Obsidian Official - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore}
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Content wrapped in `%%` is treated as a comment and will not be rendered on the page.
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
**Inline Comments:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
This is an %%inline comment%% example.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Block Comments:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
%%
|
||||||
|
This is a block comment.
|
||||||
|
It can span multiple lines.
|
||||||
|
%%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Inline Comments:**
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
This is an %%inline comment%% example.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
This is an %%inline comment%% example.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Block Comments:**
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
Content before the comment
|
||||||
|
|
||||||
|
%%
|
||||||
|
This is a block comment.
|
||||||
|
|
||||||
|
It can span multiple lines.
|
||||||
|
%%
|
||||||
|
|
||||||
|
Content after the comment
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
Content before the comment
|
||||||
|
|
||||||
|
%%
|
||||||
|
This is a block comment.
|
||||||
|
%%
|
||||||
|
|
||||||
|
It can span multiple lines.
|
||||||
|
|
||||||
|
[Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore}
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration:
|
||||||
|
|
||||||
|
```ts title=".vuepress/config.ts"
|
||||||
|
export default defineUserConfig({
|
||||||
|
theme: plumeTheme({
|
||||||
|
plugins: {
|
||||||
|
mdPower: {
|
||||||
|
obsidian: {
|
||||||
|
wikiLink: true, // Wiki Links
|
||||||
|
embedLink: true, // Embeds
|
||||||
|
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 internal link graph views, bidirectional links, etc.) are outside the scope of this support
|
||||||
|
- When embedding content, the embedded page also participates in the theme's build process
|
||||||
|
- PDF embeds require the `markdown.pdf` plugin to be enabled simultaneously
|
||||||
|
- Video embeds require the `markdown.artPlayer` plugin to be enabled simultaneously
|
||||||
|
- Embed resources starting with `/` or using `./` form are loaded from the `public` directory
|
||||||
337
docs/guide/markdown/obsidian.md
Normal file
337
docs/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
[[文件名]]
|
||||||
|
[[文件名#标题]]
|
||||||
|
[[文件名#标题#子标题]]
|
||||||
|
[[文件名|别名]]
|
||||||
|
[[文件名#标题|别名]]
|
||||||
|
[[https://example.com|外部链接]]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件名搜索规则
|
||||||
|
|
||||||
|
当使用 Wiki 链接时,文件名会按照以下规则进行搜索匹配:
|
||||||
|
|
||||||
|
**匹配优先级:**
|
||||||
|
|
||||||
|
1. **完整路径** - 精确匹配文件路径
|
||||||
|
2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径
|
||||||
|
|
||||||
|
**路径解析规则:**
|
||||||
|
|
||||||
|
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
|
||||||
|
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
|
||||||
|
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md`
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
假设文档结构如下:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/
|
||||||
|
├── README.md
|
||||||
|
├── guide/
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── markdown/
|
||||||
|
│ └── obsidian.md
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `docs/guide/markdown/obsidian.md` 中:
|
||||||
|
|
||||||
|
| 语法 | 匹配结果 |
|
||||||
|
| -------------- | -------------------------------------------------------- |
|
||||||
|
| `[[obsidian]]` | 匹配 `docs/guide/markdown/obsidian.md`(通过文件名检索) |
|
||||||
|
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
|
||||||
|
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
|
||||||
|
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
**外部链接:**
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
[[https://example.com|外部链接]]
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
|
||||||
|
[[https://example.com|外部链接]]
|
||||||
|
|
||||||
|
**内部锚点链接:**
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
[[npm-to]] <!-- 通过文件名检索 -->
|
||||||
|
[[guide/markdown/math]] <!-- 通过文件路径检索-->
|
||||||
|
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
|
||||||
|
[[file-tree#配置]] <!-- 通过文件名检索,并链接到 heading -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
|
||||||
|
[[npm-to]]
|
||||||
|
|
||||||
|
[[guide/markdown/math]]
|
||||||
|
|
||||||
|
[[#Wiki 链接]]
|
||||||
|
|
||||||
|
[[file-tree#配置]]
|
||||||
|
|
||||||
|
[Obsidian 官方 - **Wiki Links**](https://obsidian.md/zh/help/links){.readmore}
|
||||||
|
|
||||||
|
## 嵌入内容
|
||||||
|
|
||||||
|
嵌入语法允许你将其他文件资源插入到当前页面中。
|
||||||
|
|
||||||
|
### 语法
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[文件名]]
|
||||||
|
![[文件名#标题]]
|
||||||
|
![[文件名#标题#子标题]]
|
||||||
|
```
|
||||||
|
|
||||||
|
文件名搜索规则与 [Wiki 链接](#文件名搜索规则) 相同。
|
||||||
|
|
||||||
|
::: info 以 `/` 开头或 无路径前缀如 `./` 形式的,从 `public` 目录中加载资源
|
||||||
|
:::
|
||||||
|
|
||||||
|
### 图片嵌入
|
||||||
|
|
||||||
|
**语法:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[图片]]
|
||||||
|
![[图片|宽度]]
|
||||||
|
![[图片|宽度x高度]]
|
||||||
|
```
|
||||||
|
|
||||||
|
支持格式:`jpg`、`jpeg`、`png`、`gif`、`avif`、`webp`、`svg`、`bmp`、`ico`、`tiff`、`apng`、`jfif`、`pjpeg`、`pjp`、`xbm`
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
::: demo markdown title="基础图片" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: demo markdown title="设置宽度" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg|300]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: demo markdown title="设置宽度和高度" expanded
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[images/custom-hero.jpg|300x200]]
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
### PDF 嵌入
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> PDF 嵌入需要启用 `markdown.pdf` 插件才能正常工作。
|
||||||
|
|
||||||
|
**语法:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[文档.pdf]]
|
||||||
|
![[文档.pdf#page=1]] <!-- #page=1 表示第一页 -->
|
||||||
|
![[文档.pdf#page=1#height=300]] <!-- #page=页码 #height=高度 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
支持格式:`pdf`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 音频嵌入
|
||||||
|
|
||||||
|
**语法:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[音频文件]]
|
||||||
|
```
|
||||||
|
|
||||||
|
支持格式:`mp3`、`flac`、`wav`、`ogg`、`opus`、`webm`、`acc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 视频嵌入
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
|
||||||
|
|
||||||
|
**语法:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[视频文件]]
|
||||||
|
![[视频文件#height=400]] <!-- 设置视频高度 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
支持格式:`mp4`、`webm`、`mov` 等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 内容片段嵌入
|
||||||
|
|
||||||
|
通过 `#标题` 可以嵌入指定标题下的内容片段:
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
![[我的笔记]]
|
||||||
|
![[我的笔记#标题一]]
|
||||||
|
![[我的笔记#标题一#子标题]]
|
||||||
|
```
|
||||||
|
|
||||||
|
[Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore}
|
||||||
|
[Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore}
|
||||||
|
|
||||||
|
## 注释
|
||||||
|
|
||||||
|
使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。
|
||||||
|
|
||||||
|
### 语法
|
||||||
|
|
||||||
|
**行内注释:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
这是一个 %%行内注释%% 示例。
|
||||||
|
```
|
||||||
|
|
||||||
|
**块级注释:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
%%
|
||||||
|
这是一个块级注释。
|
||||||
|
可以跨越多行。
|
||||||
|
%%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
**行内注释:**
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
这是一个 %%行内注释%% 示例。
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
|
||||||
|
这是一个 %%行内注释%% 示例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**块级注释:**
|
||||||
|
|
||||||
|
**输入:**
|
||||||
|
|
||||||
|
```md
|
||||||
|
注释之前的内容
|
||||||
|
|
||||||
|
%%
|
||||||
|
这是一个块级注释。
|
||||||
|
|
||||||
|
可以跨越多行。
|
||||||
|
%%
|
||||||
|
|
||||||
|
注释之后的内容
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
|
||||||
|
注释之前的内容
|
||||||
|
|
||||||
|
%%
|
||||||
|
这是一个块级注释。
|
||||||
|
%%
|
||||||
|
|
||||||
|
可以跨越多行。
|
||||||
|
|
||||||
|
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用:
|
||||||
|
|
||||||
|
```ts title=".vuepress/config.ts"
|
||||||
|
export default defineUserConfig({
|
||||||
|
theme: plumeTheme({
|
||||||
|
plugins: {
|
||||||
|
mdPower: {
|
||||||
|
obsidian: {
|
||||||
|
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 嵌入需要同时启用 `markdown.pdf` 插件
|
||||||
|
- 视频嵌入需要同时启用 `markdown.artPlayer` 插件
|
||||||
|
- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载
|
||||||
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.base.json",
|
"extends": "../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/themes/*": ["./.vuepress/themes/*"],
|
"~/themes/*": ["./.vuepress/themes/*"],
|
||||||
"~/components/*": ["./.vuepress/themes/components/*"],
|
"~/components/*": ["./.vuepress/themes/components/*"],
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export default config({
|
|||||||
'skills',
|
'skills',
|
||||||
'docs/snippet/code-block.snippet.md',
|
'docs/snippet/code-block.snippet.md',
|
||||||
'docs/snippet/whitespace.snippet.md',
|
'docs/snippet/whitespace.snippet.md',
|
||||||
|
'docs/en/guide/markdown/obsidian.md',
|
||||||
|
'docs/guide/markdown/obsidian.md',
|
||||||
],
|
],
|
||||||
globals: {
|
globals: {
|
||||||
__VUEPRESS_VERSION__: 'readonly',
|
__VUEPRESS_VERSION__: 'readonly',
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vuepress-theme-plume-monorepo",
|
"name": "vuepress-theme-plume-monorepo",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.192",
|
"version": "1.0.0-rc.196",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||||
@ -95,10 +95,12 @@
|
|||||||
"@shikijs/twoslash": "^4.0.2",
|
"@shikijs/twoslash": "^4.0.2",
|
||||||
"@typescript-eslint/types": "catalog:peer",
|
"@typescript-eslint/types": "catalog:peer",
|
||||||
"@typescript-eslint/utils": "catalog:peer",
|
"@typescript-eslint/utils": "catalog:peer",
|
||||||
"@xmldom/xmldom": ">=0.9.9",
|
"@xmldom/xmldom": ">=0.9.10",
|
||||||
"baseline-browser-mapping": "^2.10.13",
|
"baseline-browser-mapping": "^2.10.20",
|
||||||
"chokidar": "catalog:prod",
|
"chokidar": "catalog:prod",
|
||||||
|
"dompurify": ">=3.4.0",
|
||||||
"esbuild": "catalog:prod",
|
"esbuild": "catalog:prod",
|
||||||
|
"follow-redirects": ">=1.16.0",
|
||||||
"lodash": ">=4.18.1",
|
"lodash": ">=4.18.1",
|
||||||
"lodash-es": ">=4.18.1",
|
"lodash-es": ">=4.18.1",
|
||||||
"sass-embedded": "catalog:peer",
|
"sass-embedded": "catalog:peer",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@vuepress-plume/plugin-fonts",
|
"name": "@vuepress-plume/plugin-fonts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.193",
|
"version": "1.0.0-rc.196",
|
||||||
"description": "The Plugin for VuePress 2 - fonts",
|
"description": "The Plugin for VuePress 2 - fonts",
|
||||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
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('![[]]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
85
plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts
Normal file
85
plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { App } from 'vuepress'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { obsidianPlugin } from '../src/node/obsidian/index.js'
|
||||||
|
|
||||||
|
vi.mock('vuepress/utils', async () => {
|
||||||
|
const actual = await vi.importActual('vuepress/utils')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
tinyglobby: {
|
||||||
|
globSync: vi.fn(() => []),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function createMockApp(pages: App['pages'] = []): App {
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
options: {
|
||||||
|
pagePatterns: ['**/*.md'],
|
||||||
|
},
|
||||||
|
dir: {
|
||||||
|
source: () => '/source',
|
||||||
|
},
|
||||||
|
} as unknown as App
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMarkdownWithMockRules() {
|
||||||
|
return MarkdownIt({ html: true }).use((md) => {
|
||||||
|
md.block.ruler.before('code', 'import_code', () => false)
|
||||||
|
md.renderer.rules.import_code = () => ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('obsidianPlugin', () => {
|
||||||
|
it('should enable all plugins by default', () => {
|
||||||
|
const md = createMarkdownWithMockRules()
|
||||||
|
const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages'])
|
||||||
|
obsidianPlugin(mockApp, md, {})
|
||||||
|
|
||||||
|
// Wiki link should not work since findFirstPage returns undefined when pagePaths is empty
|
||||||
|
const wikiResult = md.render('[[Home]]')
|
||||||
|
expect(wikiResult).not.toContain('<VPLink')
|
||||||
|
|
||||||
|
const commentResult = md.render('%%comment%%')
|
||||||
|
expect(commentResult).not.toContain('comment')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow disabling specific plugins', () => {
|
||||||
|
const md = createMarkdownWithMockRules()
|
||||||
|
const mockApp = createMockApp()
|
||||||
|
obsidianPlugin(mockApp, md, { obsidian: { wikiLink: false } })
|
||||||
|
|
||||||
|
const wikiResult = md.render('[[Page]]')
|
||||||
|
expect(wikiResult).not.toContain('<VPLink')
|
||||||
|
expect(wikiResult).toContain('[[Page]]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable all plugins when obsidian is false', () => {
|
||||||
|
const md = createMarkdownWithMockRules()
|
||||||
|
const mockApp = createMockApp()
|
||||||
|
obsidianPlugin(mockApp, md, { obsidian: false })
|
||||||
|
|
||||||
|
const result = md.render('![[image.png]]')
|
||||||
|
expect(result).toContain('![[image.png]]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable embedLink when explicitly set to false', () => {
|
||||||
|
const md = createMarkdownWithMockRules()
|
||||||
|
const mockApp = createMockApp()
|
||||||
|
obsidianPlugin(mockApp, md, { obsidian: { embedLink: false } })
|
||||||
|
|
||||||
|
const result = md.render('![[image.png]]')
|
||||||
|
expect(result).not.toContain('<img')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable comment when explicitly set to false', () => {
|
||||||
|
const md = createMarkdownWithMockRules()
|
||||||
|
const mockApp = createMockApp()
|
||||||
|
obsidianPlugin(mockApp, md, { obsidian: { comment: false } })
|
||||||
|
|
||||||
|
const commentResult = md.render('%%comment%%')
|
||||||
|
expect(commentResult).toContain('%%comment%%')
|
||||||
|
})
|
||||||
|
})
|
||||||
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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vuepress-plugin-md-power",
|
"name": "vuepress-plugin-md-power",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.193",
|
"version": "1.0.0-rc.196",
|
||||||
"description": "The Plugin for VuePress 2 - markdown power",
|
"description": "The Plugin for VuePress 2 - markdown power",
|
||||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -97,6 +97,7 @@
|
|||||||
"@vuepress/helper": "catalog:vuepress",
|
"@vuepress/helper": "catalog:vuepress",
|
||||||
"@vueuse/core": "catalog:prod",
|
"@vueuse/core": "catalog:prod",
|
||||||
"chokidar": "catalog:prod",
|
"chokidar": "catalog:prod",
|
||||||
|
"gray-matter": "catalog:prod",
|
||||||
"image-size": "catalog:prod",
|
"image-size": "catalog:prod",
|
||||||
"local-pkg": "catalog:prod",
|
"local-pkg": "catalog:prod",
|
||||||
"lru-cache": "catalog:prod",
|
"lru-cache": "catalog:prod",
|
||||||
|
|||||||
@ -88,6 +88,7 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
|
|||||||
let isTable = false
|
let isTable = false
|
||||||
let colIndex = 0
|
let colIndex = 0
|
||||||
let rowIndex = 0
|
let rowIndex = 0
|
||||||
|
let skipCells = 0
|
||||||
for (const token of tableTokens) {
|
for (const token of tableTokens) {
|
||||||
if (token.type === 'table_open')
|
if (token.type === 'table_open')
|
||||||
isTable = true
|
isTable = true
|
||||||
@ -100,13 +101,39 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
|
|||||||
if (token.type === 'tr_open') {
|
if (token.type === 'tr_open') {
|
||||||
rowIndex++
|
rowIndex++
|
||||||
colIndex = 0
|
colIndex = 0
|
||||||
|
// 当 th 设置了 colspan 时,需要跳过空单元格
|
||||||
|
skipCells = 0
|
||||||
}
|
}
|
||||||
// cell (rowIndex, colIndex)
|
// cell (rowIndex, colIndex)
|
||||||
if (token.type === 'th_open' || token.type === 'td_open') {
|
if (token.type === 'th_open' || token.type === 'td_open') {
|
||||||
|
if (skipCells > 0) {
|
||||||
|
// Skip this empty cell,`th` element only
|
||||||
|
if (token.type === 'th_open')
|
||||||
|
token.hidden = true
|
||||||
|
skipCells--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
colIndex++
|
colIndex++
|
||||||
const classes = cells[rowIndex]?.[colIndex] || rows[rowIndex] || cols[colIndex]
|
const classes = cells[rowIndex]?.[colIndex] || rows[rowIndex] || cols[colIndex]
|
||||||
if (classes)
|
if (classes)
|
||||||
token.attrJoin('class', classes)
|
token.attrJoin('class', classes)
|
||||||
|
|
||||||
|
// Check for colspan attribute
|
||||||
|
const colspanIndex = token.attrIndex('colspan')
|
||||||
|
if (colspanIndex >= 0) {
|
||||||
|
const colspanValue = Number.parseInt(token.attrs![colspanIndex][1])
|
||||||
|
if (!Number.isNaN(colspanValue) && colspanValue > 1) {
|
||||||
|
skipCells = colspanValue - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skip the content and close tokens for skipped cells
|
||||||
|
if (skipCells > 0 && (token.type === 'text' || token.type === 'th_close' || token.type === 'td_close')) {
|
||||||
|
if (token.type === 'th_close')
|
||||||
|
// Skip this empty cell,`th` element only
|
||||||
|
token.hidden = true
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { DemoFile, MarkdownDemoEnv } from '../../../shared/demo.js'
|
import type { DemoFile, MarkdownDemoEnv } from '../../../shared/demo.js'
|
||||||
|
|
||||||
const SCRIPT_RE = /<script.*?>/
|
const SCRIPT_RE = /<script\b[^>]*>/
|
||||||
|
|
||||||
export function insertSetupScript({ export: name, path }: DemoFile, env: MarkdownDemoEnv): void {
|
export function insertSetupScript({ export: name, path }: DemoFile, env: MarkdownDemoEnv): void {
|
||||||
const imports = `import ${name ? `${name} from ` : ''}'${path}';`
|
const imports = `import ${name ? `${name} from ` : ''}'${path}';`
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const installed = {
|
|||||||
mpegtsjs: isPackageExists('mpegts.js'),
|
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) => {
|
export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
|
||||||
createEmbedRuleBlock<ArtPlayerTokenMeta>(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)
|
if (!type)
|
||||||
return
|
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 = () => ''
|
||||||
|
}
|
||||||
311
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
311
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* 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 { attempt } from '@pengzhanbo/utils'
|
||||||
|
import grayMatter from 'gray-matter'
|
||||||
|
import Token from 'markdown-it/lib/token.mjs'
|
||||||
|
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||||
|
import { fs, hash, path } from 'vuepress/utils'
|
||||||
|
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
|
||||||
|
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
||||||
|
import { parseRect } from '../utils/parseRect.js'
|
||||||
|
import { slugify } from '../utils/slugify.js'
|
||||||
|
import { findFirstPage } from './findFirstPage.js'
|
||||||
|
|
||||||
|
interface EmbedLinkMeta {
|
||||||
|
filename: string
|
||||||
|
hashes: string[]
|
||||||
|
settings: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 pagePath = findFirstPage(filename, env.filePathRelative ?? '')
|
||||||
|
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
|
||||||
|
if (pagePath) {
|
||||||
|
const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8'))
|
||||||
|
if (error) {
|
||||||
|
console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const { content: rawContent } = grayMatter(markdown)
|
||||||
|
if (!rawContent) {
|
||||||
|
console.warn(`[embedLinkPlugin] file ${pagePath} is empty`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const content = extractContentByHeadings(rawContent, hashes)
|
||||||
|
pagePath && (env.importedFiles ??= []).push(pagePath)
|
||||||
|
return md.render(content, cleanMarkdownEnv(env))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他资源,解析为链接
|
||||||
|
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
|
||||||
|
const anchor = hashes.at(-1)
|
||||||
|
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||||
|
const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : ''))
|
||||||
|
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||||
|
md.utils.escapeHtml(text)
|
||||||
|
}</a>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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] ?? '')
|
||||||
|
}
|
||||||
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
|
||||||
|
})
|
||||||
|
}
|
||||||
32
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
32
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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 { initPagePaths } from './findFirstPage.js'
|
||||||
|
import { wikiLinkPlugin } from './wikiLink.js'
|
||||||
|
|
||||||
|
export * from './findFirstPage.js'
|
||||||
|
|
||||||
|
export function obsidianPlugin(
|
||||||
|
app: App,
|
||||||
|
md: Markdown,
|
||||||
|
options: MarkdownPowerPluginOptions,
|
||||||
|
) {
|
||||||
|
if (options.obsidian === false)
|
||||||
|
return
|
||||||
|
|
||||||
|
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
|
||||||
|
|
||||||
|
initPagePaths(app)
|
||||||
|
|
||||||
|
if (obsidian.wikiLink !== false)
|
||||||
|
wikiLinkPlugin(md)
|
||||||
|
|
||||||
|
if (obsidian.embedLink !== false)
|
||||||
|
embedLinkPlugin(md, app)
|
||||||
|
|
||||||
|
if (obsidian.comment !== false)
|
||||||
|
commentPlugin(md)
|
||||||
|
}
|
||||||
127
plugins/plugin-md-power/src/node/obsidian/wikiLink.ts
Normal file
127
plugins/plugin-md-power/src/node/obsidian/wikiLink.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Wiki Link 是属于 obsidian 官方扩展的 markdown 语法
|
||||||
|
*
|
||||||
|
* [[文件名]] [[文件名#标题]] [[文件名#标题#标题]] [[文件名#标题|别名]]
|
||||||
|
*
|
||||||
|
* @see - https://obsidian.md/zh/help/links
|
||||||
|
*
|
||||||
|
* 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
|
||||||
|
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||||
|
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||||
|
import { path } from 'vuepress/utils'
|
||||||
|
import { resolvePaths } from '../enhance/links.js'
|
||||||
|
import { slugify } from '../utils/slugify.js'
|
||||||
|
import { findFirstPage } from './findFirstPage.js'
|
||||||
|
|
||||||
|
interface WikiLinkMeta {
|
||||||
|
filename: string
|
||||||
|
alias: string
|
||||||
|
titles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const wikiLinkDef: RuleInline = (state, silent) => {
|
||||||
|
let found = false
|
||||||
|
const max = state.posMax
|
||||||
|
const start = state.pos
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.src.charCodeAt(start) !== 0x5B
|
||||||
|
|| state.src.charCodeAt(start + 1) !== 0x5B
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* istanbul ignore if -- @preserve */
|
||||||
|
if (silent)
|
||||||
|
return false
|
||||||
|
|
||||||
|
// - [[]]
|
||||||
|
if (max - start < 5)
|
||||||
|
return false
|
||||||
|
|
||||||
|
state.pos = start + 2
|
||||||
|
|
||||||
|
// 查找 ]]
|
||||||
|
while (state.pos < max) {
|
||||||
|
if (state.src.charCodeAt(state.pos) === 0x5D
|
||||||
|
&& state.src.charCodeAt(state.pos + 1) === 0x5D) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
state.md.inline.skipToken(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found || start + 2 === state.pos) {
|
||||||
|
state.pos = start
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// [[xxxx]]
|
||||||
|
// ^^^^ <- content
|
||||||
|
const content = state.src.slice(start + 2, state.pos).trim()
|
||||||
|
// found!
|
||||||
|
state.posMax = state.pos
|
||||||
|
state.pos = start + 2
|
||||||
|
|
||||||
|
const [file, alias] = content.split('|')
|
||||||
|
const [filename, ...titles] = file.trim().split('#')
|
||||||
|
|
||||||
|
const token = state.push('obsidian_wiki_link', '', 0)
|
||||||
|
token.markup = '[[]]'
|
||||||
|
token.meta = {
|
||||||
|
filename: filename.trim(),
|
||||||
|
titles: titles.map(title => title.trim()),
|
||||||
|
alias: alias?.trim(),
|
||||||
|
} as WikiLinkMeta
|
||||||
|
token.content = content
|
||||||
|
|
||||||
|
state.pos = state.posMax + 2
|
||||||
|
state.posMax = max
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wikiLinkPlugin(md: Markdown) {
|
||||||
|
md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef)
|
||||||
|
md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const { filename, titles, alias } = token.meta as WikiLinkMeta
|
||||||
|
const anchor = titles.at(-1)
|
||||||
|
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||||
|
// external link
|
||||||
|
if (isLinkHttp(filename)) {
|
||||||
|
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
|
||||||
|
return `<a href="${filename}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||||
|
md.utils.escapeHtml(text)
|
||||||
|
}</a>`
|
||||||
|
}
|
||||||
|
// internal hash link
|
||||||
|
if (!filename) { // internal page hash link
|
||||||
|
return `<VPLink href="${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||||
|
}
|
||||||
|
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
|
||||||
|
if (pagePath) {
|
||||||
|
const { absolutePath, relativePath } = resolvePaths(
|
||||||
|
pagePath,
|
||||||
|
env.base || '/',
|
||||||
|
env.filePathRelative ?? null,
|
||||||
|
)
|
||||||
|
;(env.links ??= []).push({
|
||||||
|
raw: pagePath,
|
||||||
|
absolute: absolutePath,
|
||||||
|
relative: relativePath,
|
||||||
|
})
|
||||||
|
return `<VPLink href="${ensureLeadingSlash(pagePath)}${slug}">${md.utils.escapeHtml(alias) || (titles.length ? `<template #after-text>${md.utils.escapeHtml(` > ${titles.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// other asset url
|
||||||
|
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
|
||||||
|
const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : ''))
|
||||||
|
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||||
|
md.utils.escapeHtml(text)
|
||||||
|
}</a>`
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,6 +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, 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'
|
||||||
|
|
||||||
@ -111,6 +112,8 @@ export function markdownPowerPlugin(
|
|||||||
|
|
||||||
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 () => {
|
||||||
@ -131,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')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
* Whether to hide toolbar
|
||||||
*
|
*
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { IconOptions } from './icon.js'
|
|||||||
import type { MDPowerLocaleData } from './locale.js'
|
import type { MDPowerLocaleData } from './locale.js'
|
||||||
import type { MarkOptions } from './mark.js'
|
import type { MarkOptions } from './mark.js'
|
||||||
import type { NpmToOptions } from './npmTo.js'
|
import type { NpmToOptions } from './npmTo.js'
|
||||||
|
import type { ObsidianOptions } from './obsidian.js'
|
||||||
import type { PDFOptions } from './pdf.js'
|
import type { PDFOptions } from './pdf.js'
|
||||||
import type { PlotOptions } from './plot.js'
|
import type { PlotOptions } from './plot.js'
|
||||||
import type { ReplOptions } from './repl.js'
|
import type { ReplOptions } from './repl.js'
|
||||||
@ -406,5 +407,12 @@ export interface MarkdownPowerPluginOptions {
|
|||||||
*/
|
*/
|
||||||
imageSize?: boolean | 'local' | 'all'
|
imageSize?: boolean | 'local' | 'all'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用 obsidian 官方 markdown 扩展语法的兼容性支持
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
obsidian?: boolean | ObsidianOptions
|
||||||
|
|
||||||
locales?: LocaleConfig<MDPowerLocaleData>
|
locales?: LocaleConfig<MDPowerLocaleData>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@vuepress-plume/plugin-search",
|
"name": "@vuepress-plume/plugin-search",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.193",
|
"version": "1.0.0-rc.196",
|
||||||
"description": "The Plugin for VuePress 2 - local search",
|
"description": "The Plugin for VuePress 2 - local search",
|
||||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -165,6 +165,14 @@ export async function prepareSearchIndex({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updateQueue = Promise.resolve()
|
||||||
|
|
||||||
|
async function queueUpdateIndexFile({ page, isSearchable, searchOptions }: UpdateSearchIndexOptions) {
|
||||||
|
updateQueue = updateQueue
|
||||||
|
.then(() => indexFile(page, searchOptions, isSearchable))
|
||||||
|
return updateQueue
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle search index update when a page is modified.
|
* Handle search index update when a page is modified.
|
||||||
*
|
*
|
||||||
@ -187,7 +195,8 @@ export async function onSearchIndexUpdated(
|
|||||||
if (isSearchable && !isSearchable(page))
|
if (isSearchable && !isSearchable(page))
|
||||||
return
|
return
|
||||||
|
|
||||||
await indexFile(page, searchOptions, isSearchable)
|
// FIXME: onPageUpdated 存在竞态问题,当前使用异步队列避免
|
||||||
|
await queueUpdateIndexFile({ page, isSearchable, searchOptions })
|
||||||
await writeTemp(app)
|
await writeTemp(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,8 +306,11 @@ async function indexFile(page: Page, options: SearchIndexOptions['searchOptions'
|
|||||||
${page.contentRendered}`
|
${page.contentRendered}`
|
||||||
const sections = splitPageIntoSections(html)
|
const sections = splitPageIntoSections(html)
|
||||||
|
|
||||||
if (cache && cache.length)
|
try {
|
||||||
index.removeAll(cache)
|
if (cache && cache.length)
|
||||||
|
index.removeAll(cache)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
|
||||||
// add sections to the locale index
|
// add sections to the locale index
|
||||||
for await (const section of sections) {
|
for await (const section of sections) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { SearchPluginOptions } from '../shared/index.js'
|
|||||||
import { addViteOptimizeDepsInclude, getFullLocaleConfig } from '@vuepress/helper'
|
import { addViteOptimizeDepsInclude, getFullLocaleConfig } from '@vuepress/helper'
|
||||||
import { getDirname, path } from 'vuepress/utils'
|
import { getDirname, path } from 'vuepress/utils'
|
||||||
import { SEARCH_LOCALES } from './locales/index.js'
|
import { SEARCH_LOCALES } from './locales/index.js'
|
||||||
import { onSearchIndexRemoved, onSearchIndexUpdated, prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
|
import { /* onSearchIndexRemoved, onSearchIndexUpdated, */ prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
|
||||||
|
|
||||||
const __dirname = getDirname(import.meta.url)
|
const __dirname = getDirname(import.meta.url)
|
||||||
|
|
||||||
@ -71,16 +71,16 @@ export function searchPlugin({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onPageUpdated: async (app, type, page) => {
|
// onPageUpdated: async (app, type, page) => {
|
||||||
if (!page?.filePathRelative)
|
// if (!page?.filePathRelative)
|
||||||
return
|
// return
|
||||||
|
|
||||||
if (type === 'create' || type === 'update') {
|
// if (type === 'create' || type === 'update') {
|
||||||
await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
|
// await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
|
||||||
}
|
// }
|
||||||
else if (type === 'delete') {
|
// else if (type === 'delete') {
|
||||||
await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
|
// await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
2995
pnpm-lock.yaml
generated
2995
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -24,67 +24,67 @@ catalogs:
|
|||||||
'@commitlint/cli': ^20.5.0
|
'@commitlint/cli': ^20.5.0
|
||||||
'@commitlint/config-conventional': ^20.5.0
|
'@commitlint/config-conventional': ^20.5.0
|
||||||
'@lunariajs/core': ^0.1.1
|
'@lunariajs/core': ^0.1.1
|
||||||
'@pengzhanbo/eslint-config-vue': ^2.2.0
|
'@pengzhanbo/eslint-config-vue': ^2.4.0
|
||||||
'@pengzhanbo/stylelint-config': ^2.2.0
|
'@pengzhanbo/stylelint-config': ^2.4.0
|
||||||
'@simonwep/pickr': ^1.9.1
|
'@simonwep/pickr': ^1.9.1
|
||||||
'@types/express': ^5.0.6
|
'@types/express': ^5.0.6
|
||||||
'@types/js-yaml': ^4.0.9
|
'@types/js-yaml': ^4.0.9
|
||||||
'@types/less': ^3.0.8
|
'@types/less': ^3.0.8
|
||||||
'@types/markdown-it': ^14.1.2
|
'@types/markdown-it': ^14.1.2
|
||||||
'@types/minimist': ^1.2.5
|
'@types/minimist': ^1.2.5
|
||||||
'@types/node': ^25.5.0
|
'@types/node': ^25.6.0
|
||||||
'@types/picomatch': ^4.0.2
|
'@types/picomatch': ^4.0.3
|
||||||
'@types/qrcode': ^1.5.6
|
'@types/qrcode': ^1.5.6
|
||||||
'@types/stylus': ^0.48.43
|
'@types/stylus': ^0.48.43
|
||||||
'@types/three': ^0.183.1
|
'@types/three': ^0.184.0
|
||||||
'@types/webpack-env': ^1.18.8
|
'@types/webpack-env': ^1.18.8
|
||||||
'@vitest/coverage-v8': ^4.1.2
|
'@vitest/coverage-v8': ^4.1.4
|
||||||
bumpp: ^11.0.1
|
bumpp: ^11.0.1
|
||||||
commitizen: ^4.3.1
|
commitizen: ^4.3.1
|
||||||
conventional-changelog: ^7.2.0
|
conventional-changelog: ^7.2.0
|
||||||
conventional-changelog-angular: ^8.3.1
|
conventional-changelog-angular: ^8.3.1
|
||||||
cpx2: ^8.0.0
|
cpx2: ^8.0.2
|
||||||
cross-env: 7.0.3
|
cross-env: 7.0.3
|
||||||
cz-conventional-changelog: ^3.3.0
|
cz-conventional-changelog: ^3.3.0
|
||||||
eslint: ^10.1.0
|
eslint: ^10.2.1
|
||||||
http-server: ^14.1.1
|
http-server: ^14.1.1
|
||||||
husky: ^9.1.7
|
husky: ^9.1.7
|
||||||
less: ^4.6.4
|
less: ^4.6.4
|
||||||
lint-staged: ^16.4.0
|
lint-staged: ^16.4.0
|
||||||
markdown-it: ^14.1.1
|
markdown-it: ^14.1.1
|
||||||
memfs: ^4.57.1
|
memfs: ^4.57.2
|
||||||
mermaid: ^11.14.0
|
mermaid: ^11.14.0
|
||||||
minimist: ^1.2.8
|
minimist: ^1.2.8
|
||||||
postcss: ^8.5.8
|
postcss: ^8.5.10
|
||||||
rimraf: ^6.1.3
|
rimraf: ^6.1.3
|
||||||
stylelint: ^17.6.0
|
stylelint: ^17.8.0
|
||||||
stylus: ^0.64.0
|
stylus: ^0.64.0
|
||||||
tsconfig-vuepress: ^7.0.0
|
tsconfig-vuepress: ^7.0.0
|
||||||
tsdown: ^0.21.7
|
tsdown: ^0.21.9
|
||||||
typescript: ^5.9.3
|
typescript: ^6.0.3
|
||||||
vite: ^8.0.3
|
vite: ^8.0.8
|
||||||
vitest: ^4.1.2
|
vitest: ^4.1.4
|
||||||
wait-on: ^9.0.4
|
wait-on: ^9.0.5
|
||||||
peer:
|
peer:
|
||||||
'@eslint-community/eslint-utils': ^4.9.1
|
'@eslint-community/eslint-utils': ^4.9.1
|
||||||
'@iconify/json': ^2.2.458
|
'@iconify/json': ^2.2.464
|
||||||
'@mathjax/src': ^4.1.1
|
'@mathjax/src': ^4.1.1
|
||||||
'@pinyin-pro/data': ^1.3.1
|
'@pinyin-pro/data': ^1.3.1
|
||||||
'@typescript-eslint/types': ^8.58.0
|
'@typescript-eslint/types': ^8.58.2
|
||||||
'@typescript-eslint/utils': ^8.58.0
|
'@typescript-eslint/utils': ^8.58.2
|
||||||
artplayer: ^5.4.0
|
artplayer: ^5.4.0
|
||||||
dashjs: ^5.1.1
|
dashjs: ^5.1.1
|
||||||
gsap: ^3.14.2
|
gsap: ^3.15.0
|
||||||
hls.js: ^1.6.15
|
hls.js: ^1.6.16
|
||||||
mpegts.js: 1.7.3
|
mpegts.js: 1.7.3
|
||||||
ogl: ^1.0.11
|
ogl: ^1.0.11
|
||||||
pinyin-pro: ^3.28.0
|
pinyin-pro: ^3.28.1
|
||||||
postprocessing: ^6.39.0
|
postprocessing: ^6.39.1
|
||||||
pyodide: ^0.29.3
|
pyodide: ^0.29.3
|
||||||
sass: ^1.98.0
|
sass: ^1.99.0
|
||||||
sass-embedded: ^1.98.0
|
sass-embedded: ^1.99.0
|
||||||
swiper: ^12.1.3
|
swiper: ^12.1.3
|
||||||
three: ^0.183.2
|
three: ^0.184.0
|
||||||
prod:
|
prod:
|
||||||
'@clack/prompts': ^1.2.0
|
'@clack/prompts': ^1.2.0
|
||||||
'@iconify/utils': ^3.1.0
|
'@iconify/utils': ^3.1.0
|
||||||
@ -104,7 +104,7 @@ catalogs:
|
|||||||
chokidar: 5.0.0
|
chokidar: 5.0.0
|
||||||
dayjs: ^1.11.20
|
dayjs: ^1.11.20
|
||||||
echarts: ^6.0.0
|
echarts: ^6.0.0
|
||||||
esbuild: ^0.27.5
|
esbuild: ^0.28.0
|
||||||
flowchart.ts: ^3.0.1
|
flowchart.ts: ^3.0.1
|
||||||
focus-trap: ^8.0.1
|
focus-trap: ^8.0.1
|
||||||
gray-matter: ^4.0.3
|
gray-matter: ^4.0.3
|
||||||
@ -112,9 +112,9 @@ catalogs:
|
|||||||
hash-wasm: ^4.12.0
|
hash-wasm: ^4.12.0
|
||||||
image-size: ^2.0.2
|
image-size: ^2.0.2
|
||||||
js-yaml: ^4.1.1
|
js-yaml: ^4.1.1
|
||||||
katex: ^0.16.44
|
katex: ^0.16.45
|
||||||
local-pkg: ^1.1.2
|
local-pkg: ^1.1.2
|
||||||
lru-cache: ^11.2.7
|
lru-cache: ^11.3.5
|
||||||
mark.js: ^8.11.1
|
mark.js: ^8.11.1
|
||||||
markdown-it-cjk-friendly: ^2.0.2
|
markdown-it-cjk-friendly: ^2.0.2
|
||||||
markdown-it-container: ^4.0.0
|
markdown-it-container: ^4.0.0
|
||||||
@ -123,7 +123,7 @@ catalogs:
|
|||||||
markmap-view: ^0.18.12
|
markmap-view: ^0.18.12
|
||||||
minisearch: ^7.2.0
|
minisearch: ^7.2.0
|
||||||
nano-spawn: ^2.1.0
|
nano-spawn: ^2.1.0
|
||||||
nanoid: ^5.1.7
|
nanoid: ^5.1.9
|
||||||
os-locale: ^8.0.0
|
os-locale: ^8.0.0
|
||||||
p-map: ^7.0.4
|
p-map: ^7.0.4
|
||||||
package-manager-detector: ^1.6.0
|
package-manager-detector: ^1.6.0
|
||||||
@ -134,7 +134,7 @@ catalogs:
|
|||||||
sort-package-json: ^3.6.1
|
sort-package-json: ^3.6.1
|
||||||
tm-grammars: ^1.31.15
|
tm-grammars: ^1.31.15
|
||||||
tm-themes: ^1.12.2
|
tm-themes: ^1.12.2
|
||||||
vue: ^3.5.31
|
vue: ^3.5.32
|
||||||
vue-router: ^5.0.4
|
vue-router: ^5.0.4
|
||||||
vuepress:
|
vuepress:
|
||||||
'@vuepress/bundler-vite': 2.0.0-rc.28
|
'@vuepress/bundler-vite': 2.0.0-rc.28
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vuepress-theme-plume",
|
"name": "vuepress-theme-plume",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0-rc.193",
|
"version": "1.0.0-rc.196",
|
||||||
"description": "A Blog&Document Theme for VuePress 2.0",
|
"description": "A Blog&Document Theme for VuePress 2.0",
|
||||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import { computed, toRef } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { useRouter, withBase } from 'vuepress/client'
|
import { useRouter, withBase } from 'vuepress/client'
|
||||||
import { useData, useLink } from '../composables/index.js'
|
import { useData, useLink } from '../composables/index.js'
|
||||||
|
import { resolveNavLink } from '../utils/index.js'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
tag?: string
|
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 { 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) {
|
function linkTo(e: Event) {
|
||||||
if (!isExternal.value && link.value) {
|
if (!isExternal.value && link.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -36,9 +44,8 @@ function linkTo(e: Event) {
|
|||||||
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
|
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
|
||||||
@click="linkTo($event)"
|
@click="linkTo($event)"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>{{ resolvedText || href }}</slot>
|
||||||
{{ text || href }}
|
<slot name="after-text" />
|
||||||
</slot>
|
|
||||||
<span v-if="isExternal && !noIcon" class="visually-hidden">
|
<span v-if="isExternal && !noIcon" class="visually-hidden">
|
||||||
{{ theme.openNewWindowText || '(Open in new window)' }}
|
{{ theme.openNewWindowText || '(Open in new window)' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export function useLink(
|
|||||||
const maybeIsExternal = computed(() => {
|
const maybeIsExternal = computed(() => {
|
||||||
const link = toValue(href)
|
const link = toValue(href)
|
||||||
const rawTarget = toValue(target)
|
const rawTarget = toValue(target)
|
||||||
if (!link)
|
if (!link || link[0] === '#')
|
||||||
return false
|
return false
|
||||||
if (rawTarget === '_blank' || isLinkExternal(link))
|
if (rawTarget === '_blank' || isLinkExternal(link))
|
||||||
return true
|
return true
|
||||||
@ -70,8 +70,12 @@ export function useLink(
|
|||||||
if (!link || maybeIsExternal.value)
|
if (!link || maybeIsExternal.value)
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
if (link[0] === '#')
|
||||||
|
return page.value.path + link
|
||||||
|
|
||||||
const currentPath = page.value.filePathRelative ? `/${page.value.filePathRelative}` : undefined
|
const currentPath = page.value.filePathRelative ? `/${page.value.filePathRelative}` : undefined
|
||||||
const path = resolveRouteFullPath(link, currentPath)
|
const path = resolveRouteFullPath(link, currentPath)
|
||||||
|
|
||||||
if (path.includes('#')) {
|
if (path.includes('#')) {
|
||||||
// Compare path + anchor with current route path
|
// Compare path + anchor with current route path
|
||||||
// Convert to anchor link to avoid page refresh
|
// Convert to anchor link to avoid page refresh
|
||||||
|
|||||||
@ -189,6 +189,12 @@ const copyPageText = computed(() => {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.vp-page-context-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.page-context-button {
|
.page-context-button {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -67,6 +67,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
|
|||||||
'youtube',
|
'youtube',
|
||||||
'qrcode',
|
'qrcode',
|
||||||
'encrypt',
|
'encrypt',
|
||||||
|
'obsidian',
|
||||||
'locales',
|
'locales',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -46,12 +46,11 @@ export function resolveMatcherPattern(include?: string | string[], exclude?: str
|
|||||||
*
|
*
|
||||||
* @param include - Patterns to include / 要包含的模式
|
* @param include - Patterns to include / 要包含的模式
|
||||||
* @param exclude - Patterns to exclude / 要排除的模式
|
* @param exclude - Patterns to exclude / 要排除的模式
|
||||||
* @param cwd - Current working directory for relative path matching / 用于相对路径匹配的当前工作目录
|
|
||||||
* @returns Matcher function that tests file paths / 测试文件路径的匹配器函数
|
* @returns Matcher function that tests file paths / 测试文件路径的匹配器函数
|
||||||
*/
|
*/
|
||||||
export function createMatcher(include?: string | string[], exclude?: string | string[], cwd?: string): Matcher {
|
export function createMatcher(include?: string | string[], exclude?: string | string[]): Matcher {
|
||||||
exclude = ['**/node_modules/**', '**/.vuepress/**', ...toArray(exclude)]
|
exclude = ['**/node_modules/**', '**/.vuepress/**', ...toArray(exclude)]
|
||||||
const { pattern, ignore } = resolveMatcherPattern(include, exclude)
|
const { pattern, ignore } = resolveMatcherPattern(include, exclude)
|
||||||
|
|
||||||
return picomatch(pattern, { ignore, cwd })
|
return picomatch(pattern, { ignore })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
"extends": "./tsconfig.base.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
|
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
|
||||||
"@theme/*": ["./theme/src/client/components/*"]
|
"@theme/*": ["./theme/src/client/components/*"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user