mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-05-01 12:38:12 +08:00
Compare commits
29 Commits
v1.0.0-rc.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e2d2b3dc1 | ||
|
|
475d7f2db1 | ||
|
|
5d5b5399ff | ||
|
|
26c588ab23 | ||
|
|
a9e7ebd6ba | ||
|
|
32fb93bf35 | ||
|
|
4614041bbf | ||
|
|
a9ddb04acd | ||
|
|
3265be84a9 | ||
|
|
2bfdec82d7 | ||
|
|
ac63654151 | ||
|
|
6ed5a5c552 | ||
|
|
d69e0b9765 | ||
|
|
02038f2df0 | ||
|
|
e5126663ef | ||
|
|
402f259086 | ||
|
|
58ea2fc8cb | ||
|
|
6ebb1bda6e | ||
|
|
68f39695c4 | ||
|
|
76787f6530 | ||
|
|
e2b47da532 | ||
|
|
035d521e96 | ||
|
|
bfd0c8409c | ||
|
|
e11c7a8fcd | ||
|
|
1329051536 | ||
|
|
0677f6749e | ||
|
|
28963eb419 | ||
|
|
cfc89adab8 | ||
|
|
e0ba59a6f9 |
3
.github/workflows/docs-deploy.yaml
vendored
3
.github/workflows/docs-deploy.yaml
vendored
@ -13,6 +13,9 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -6,6 +6,9 @@ on:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
3
.github/workflows/lint.yaml
vendored
3
.github/workflows/lint.yaml
vendored
@ -8,6 +8,9 @@ on:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
7
.github/workflows/release.yaml
vendored
7
.github/workflows/release.yaml
vendored
@ -5,6 +5,10 @@ on:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yaml
|
||||
@ -16,9 +20,6 @@ jobs:
|
||||
if: github.repository == 'pengzhanbo/vuepress-theme-plume'
|
||||
needs: [test, lint]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
3
.github/workflows/test.yaml
vendored
3
.github/workflows/test.yaml
vendored
@ -8,6 +8,9 @@ on:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
548
CHANGELOG.md
548
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "create-vuepress-theme-plume",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"description": "The cli for create vuepress-theme-plume's project",
|
||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||
"license": "MIT",
|
||||
@ -40,8 +40,8 @@
|
||||
"sort-package-json": "catalog:prod"
|
||||
},
|
||||
"plume-deps": {
|
||||
"vuepress": "2.0.0-rc.26",
|
||||
"vue": "^3.5.26",
|
||||
"vuepress": "2.0.0-rc.28",
|
||||
"vue": "^3.5.32",
|
||||
"http-server": "^14.1.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
||||
'chat',
|
||||
'include',
|
||||
'env',
|
||||
'obsidian',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({
|
||||
'chat',
|
||||
'include',
|
||||
'env',
|
||||
'obsidian',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
BIN
docs/.vuepress/public/images/demos/plume.webp
Normal file
BIN
docs/.vuepress/public/images/demos/plume.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
@ -58,6 +58,7 @@ export const theme: Theme = plumeTheme({
|
||||
jsfiddle: true,
|
||||
demo: true,
|
||||
encrypt: true,
|
||||
obsidian: true,
|
||||
npmTo: ['pnpm', 'yarn', 'npm'],
|
||||
repl: {
|
||||
go: true,
|
||||
|
||||
@ -17,7 +17,7 @@ docs:
|
||||
logo: /plume.png
|
||||
url: https://theme-plume.vuejs.press
|
||||
repo: https://github.com/pengzhanbo/vuepress-theme-plume
|
||||
preview: /images/demos/plume.jpg
|
||||
preview: /images/demos/plume.webp
|
||||
-
|
||||
name: city walk 城市漫步
|
||||
desc: 致力于汇聚全国350多个城市的户外活动地点与文化场馆的开放数据平台。
|
||||
@ -25,12 +25,6 @@ docs:
|
||||
url: https://shenzhen.citywalk.group/
|
||||
repo: https://github.com/sunshang-hl/CityWalk
|
||||
preview: https://pub-187e90a3327b41ccb8869558b6b8bbc0.r2.dev/city-shenzhen/2024/12/ed251c4438f722dffd6cb95db86c0d56.jpg
|
||||
-
|
||||
name: 哦麦 MC
|
||||
desc: 我的世界教学文档。
|
||||
logo: https://s.xc.life/img/img/minecraft-154749_1280.png
|
||||
url: https://ohmymc.com/
|
||||
preview: https://s.xc.life/img/img/20241228225159139.png
|
||||
-
|
||||
name: NcatBotDocs
|
||||
desc: NcatBot,一个 QQ 机器人框架项目的使用文档。
|
||||
@ -96,8 +90,8 @@ docs:
|
||||
-
|
||||
name: mcenahle Docs
|
||||
desc: mcenahle 的文档网站。
|
||||
logo: https://d.mcenahle.cn/images/logo.png
|
||||
url: https://d.mcenahle.cn/
|
||||
logo: https://d.mcenahle.com/images/logo.png
|
||||
url: https://d.mcenahle.com/
|
||||
preview: https://mcenahle.cn/resources/docs-site-preview.jpg
|
||||
|
||||
blog:
|
||||
@ -192,13 +186,6 @@ blog:
|
||||
url: https://ar0m.com
|
||||
repo: https://github.com/jindongjie/blog-vuepress-2025
|
||||
preview: /images/demos/jindongjie.jpg
|
||||
-
|
||||
name: 艺述论
|
||||
desc: 一枚喜受艺术的程序员's blog
|
||||
logo: https://yishulun.com/avatar.png
|
||||
url: https://yishulun.com
|
||||
repo: https://github.com/rixingyike/rixingyike.github.io
|
||||
preview: /images/demos/yishulun.com.jpg
|
||||
-
|
||||
name: 菲兹克斯喵
|
||||
desc: 一名物理系学生的笔记和生活
|
||||
@ -294,6 +281,12 @@ blog:
|
||||
|
||||
[前往 **Github Pull Request** 提交站点](https://github.com/pengzhanbo/vuepress-theme-plume/edit/main/docs/demos.md){.read-more}
|
||||
|
||||
::: info 案例每半年检查一次,以下情况的站点将会被移除
|
||||
|
||||
- 站点链接无法访问
|
||||
- 已不再使用 vuepress-theme-plume 主题
|
||||
:::
|
||||
|
||||
## 文档
|
||||
|
||||
<Demos :list="$frontmatter.docs" />
|
||||
|
||||
461
docs/en/guide/markdown/obsidian.md
Normal file
461
docs/en/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,461 @@
|
||||
---
|
||||
title: Obsidian Compatibility
|
||||
icon: simple-icons:obsidian
|
||||
createTime: 2026/04/17 21:56:55
|
||||
permalink: /en/guide/markdown/obsidian/
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The theme provides compatibility support for Obsidian's official Markdown extension syntax through the `vuepress-plugin-md-power` plugin,
|
||||
enabling Obsidian users to write documentation using familiar syntax.
|
||||
|
||||
Currently supported Obsidian extension syntax includes:
|
||||
|
||||
- [Wiki Links](#wiki-links) - Syntax for inter-page linking
|
||||
- [Embeds](#embeds) - Embed content from other files into the current page
|
||||
- [Callout](#callout) - Highlight important information with styled containers
|
||||
- [Comments](#comments) - Add comments visible only during editing
|
||||
|
||||
::: warning No plans to support extension syntax provided by Obsidian's third-party community plugins
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration:
|
||||
|
||||
```ts title=".vuepress/config.ts"
|
||||
export default defineUserConfig({
|
||||
theme: plumeTheme({
|
||||
plugins: {
|
||||
mdPower: {
|
||||
obsidian: {
|
||||
wikiLink: true, // Wiki Links
|
||||
embedLink: true, // Embeds
|
||||
callout: true, // Callout
|
||||
comment: true, // Comments
|
||||
},
|
||||
pdf: true, // PDF embed functionality
|
||||
artPlayer: true, // Video embed functionality
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
:::: field-group
|
||||
|
||||
::: field name="wikiLink" type="boolean" default="true" optional
|
||||
Enable [Wiki Links](#wiki-links) syntax.
|
||||
:::
|
||||
|
||||
::: field name="embedLink" type="boolean" default="true" optional
|
||||
Enable [Embeds](#embeds) syntax.
|
||||
:::
|
||||
|
||||
::: field name="callout" type="boolean" default="true" optional
|
||||
Enable [Callout](#callout) syntax.
|
||||
:::
|
||||
|
||||
::: field name="comment" type="boolean" default="true" optional
|
||||
Enable [Comments](#comments) syntax.
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## Wiki Links
|
||||
|
||||
Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links.
|
||||
|
||||
### Syntax
|
||||
|
||||
```md
|
||||
[[filename]]
|
||||
[[filename#heading]]
|
||||
[[filename#heading#subheading]]
|
||||
[[filename|alias]]
|
||||
[[filename#heading|alias]]
|
||||
[[https://example.com|External Link]]
|
||||
```
|
||||
|
||||
### Filename Search Rules
|
||||
|
||||
When using Wiki Links, filenames are matched according to the following rules:
|
||||
|
||||
**Match Priority:**
|
||||
|
||||
1. **Full Path** - Exact match against file paths
|
||||
2. **Fuzzy Match** - Match filenames at the end of paths, prioritizing the shortest path
|
||||
|
||||
**Path Resolution Rules:**
|
||||
|
||||
- **Relative paths** (starting with `.`): Resolved relative to the current file's directory
|
||||
- **Absolute paths** (not starting with `.`): Searched throughout the document tree, prioritizing the shortest path
|
||||
- **Directory form** (ending with `/`): Matches `README.md` in that directory
|
||||
|
||||
**Example:**
|
||||
|
||||
Assuming the following document structure:
|
||||
|
||||
```txt
|
||||
docs/
|
||||
├── README.md
|
||||
├── guide/
|
||||
│ ├── README.md
|
||||
│ └── markdown/
|
||||
│ └── obsidian.md
|
||||
```
|
||||
|
||||
In `docs/guide/markdown/obsidian.md`:
|
||||
|
||||
| Syntax | Match Result |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `[[obsidian]]` | Matches `docs/guide/markdown/obsidian.md` (matched via filename) |
|
||||
| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) |
|
||||
| `[[../]]` | Matches `docs/guide/README.md` (parent directory) |
|
||||
| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) |
|
||||
|
||||
### Examples
|
||||
|
||||
**External Links:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
[[https://example.com|External Link]]
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
[[https://example.com|External Link]]
|
||||
|
||||
**Internal Anchor Links:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
[[npm-to]] <!-- Search by filename -->
|
||||
[[guide/markdown/math]] <!-- Search by file path -->
|
||||
[[#Wiki Links]] <!-- Heading on current page -->
|
||||
[[file-tree#Configuration]] <!-- Search by filename, link to heading -->
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
[[npm-to]]
|
||||
|
||||
[[guide/markdown/math]]
|
||||
|
||||
[[#Wiki Links]]
|
||||
|
||||
[[file-tree#Configuration]]
|
||||
|
||||
[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore}
|
||||
|
||||
## Embeds
|
||||
|
||||
The embed syntax allows you to insert other file resources into the current page.
|
||||
|
||||
### Syntax
|
||||
|
||||
```md
|
||||
![[filename]]
|
||||
![[filename#heading]]
|
||||
![[filename#heading#subheading]]
|
||||
```
|
||||
|
||||
Filename search rules are the same as [Wiki Links](#filename-search-rules).
|
||||
|
||||
::: info Resources starting with `/` or having no path prefix like `./` are loaded from the `public` directory
|
||||
:::
|
||||
|
||||
### Image Embeds
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[image]]
|
||||
![[image|300]]
|
||||
![[image|300x200]]
|
||||
```
|
||||
|
||||
Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm`
|
||||
|
||||
**Example:**
|
||||
|
||||
::: demo markdown title="Basic Image" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: demo markdown title="Set Width" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg|300]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: demo markdown title="Set Width and Height" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg|300x200]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### PDF Embeds
|
||||
|
||||
> [!NOTE]
|
||||
> PDF embeds require the `markdown.pdf` plugin to be enabled for proper functionality.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[document.pdf]]
|
||||
![[document.pdf#page=1]] <!-- #page=1 means first page -->
|
||||
![[document.pdf#page=1#height=300]] <!-- #page=page number #height=height -->
|
||||
```
|
||||
|
||||
Supported formats: `pdf`
|
||||
|
||||
---
|
||||
|
||||
### Audio Embeds
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[audio file]]
|
||||
```
|
||||
|
||||
Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc`
|
||||
|
||||
---
|
||||
|
||||
### Video Embeds
|
||||
|
||||
> [!NOTE]
|
||||
> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```md
|
||||
![[video file]]
|
||||
![[video file#height=400]] <!-- Set video height -->
|
||||
```
|
||||
|
||||
Supported formats: `mp4`, `webm`, `mov`, etc.
|
||||
|
||||
---
|
||||
|
||||
### Content Fragment Embeds
|
||||
|
||||
Content fragments under a specified heading can be embedded using `#heading`:
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
![[my-note]]
|
||||
![[my-note#Heading One]]
|
||||
![[my-note#Heading One#Subheading]]
|
||||
```
|
||||
|
||||
[Obsidian Official - **Insert Files**](https://obsidian.md/en/help/embeds){.readmore}
|
||||
[Obsidian Official - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore}
|
||||
|
||||
## Callout
|
||||
|
||||
Callout is a syntax for highlighting important information, similar to VuePress's `::: hint` container syntax.
|
||||
|
||||
### Syntax
|
||||
|
||||
```md
|
||||
> [!note]
|
||||
> Content
|
||||
```
|
||||
|
||||
**Optional Title:**
|
||||
|
||||
```md
|
||||
> [!tip] Custom Title
|
||||
> Content
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
Callout supports the following types, with aliases automatically mapped to their corresponding primary types:
|
||||
|
||||
| Type | Aliases | Description |
|
||||
| ---- | ------- | ----------- |
|
||||
| `note` | `quote`, `cite` | Notes, quotes |
|
||||
| `tip` | `hint` | Tips, hints |
|
||||
| `info` | `todo` | Information, todos |
|
||||
| `success` | `check`, `done` | Success, done |
|
||||
| `warning` | `question`, `help`, `faq` | Warnings, questions, help |
|
||||
| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | Caution, failure, danger |
|
||||
| `important` | `example` | Important, examples |
|
||||
| `details` | `abstract`, `summary`, `tldr` | Details, summary |
|
||||
|
||||
### Examples
|
||||
|
||||
**Basic Usage:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
> [!NOTE]
|
||||
> This is a note callout.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
> [!NOTE]
|
||||
> This is a note callout.
|
||||
|
||||
---
|
||||
|
||||
**With Title:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
> [!TIP] Useful Tip
|
||||
> Using `pnpm` can significantly speed up dependency installation.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
> [!TIP] Useful Tip
|
||||
> Using `pnpm` can significantly speed up dependency installation.
|
||||
|
||||
---
|
||||
|
||||
**Multiple Types:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
> [!success]
|
||||
> Operation completed successfully!
|
||||
>
|
||||
> [!warning]
|
||||
> This is a warning message.
|
||||
>
|
||||
> [!caution]
|
||||
> Please proceed with caution, this action cannot be undone.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
> [!success]
|
||||
> Operation completed successfully!
|
||||
|
||||
> [!warning]
|
||||
> This is a warning message.
|
||||
|
||||
> [!caution]
|
||||
> Please proceed with caution, this action cannot be undone.
|
||||
|
||||
---
|
||||
|
||||
**Details Type:**
|
||||
|
||||
The `details` type renders as an HTML `<details>` element, supporting collapse/expand:
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
> [!details]
|
||||
> Click to expand more content
|
||||
>
|
||||
> This is hidden content.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
> [!details]
|
||||
> Click to expand more content
|
||||
>
|
||||
> This is hidden content.
|
||||
|
||||
[Obsidian Official - **Callout**](https://obsidian.md/en/help/callouts){.readmore}
|
||||
|
||||
## Comments
|
||||
|
||||
Content wrapped in `%%` is treated as a comment and will not be rendered on the page.
|
||||
|
||||
### Syntax
|
||||
|
||||
**Inline Comments:**
|
||||
|
||||
```md
|
||||
This is an %%inline comment%% example.
|
||||
```
|
||||
|
||||
**Block Comments:**
|
||||
|
||||
```md
|
||||
%%
|
||||
This is a block comment.
|
||||
It can span multiple lines.
|
||||
%%
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Inline Comments:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
This is an %%inline comment%% example.
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
This is an %%inline comment%% example.
|
||||
|
||||
---
|
||||
|
||||
**Block Comments:**
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
Content before the comment
|
||||
|
||||
%%
|
||||
This is a block comment.
|
||||
|
||||
It can span multiple lines.
|
||||
%%
|
||||
|
||||
Content after the comment
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
Content before the comment
|
||||
|
||||
%%
|
||||
This is a block comment.
|
||||
%%
|
||||
|
||||
It can span multiple lines.
|
||||
|
||||
[Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore}
|
||||
|
||||
## Notes
|
||||
|
||||
- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality
|
||||
- Some Obsidian-specific features (such as internal link graph views, bidirectional links, etc.) are outside the scope of this support
|
||||
- When embedding content, the embedded page also participates in the theme's build process
|
||||
- PDF embeds require the `markdown.pdf` plugin to be enabled simultaneously
|
||||
- Video embeds require the `markdown.artPlayer` plugin to be enabled simultaneously
|
||||
- Embed resources starting with `/` or using `./` form are loaded from the `public` directory
|
||||
@ -6,7 +6,7 @@ permalink: /en/guide/markdown/qrcode/
|
||||
badge: New
|
||||
---
|
||||
|
||||
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
## Overview
|
||||
|
||||
@ -42,7 +42,7 @@ Inline syntax is suitable for shorter `text`, such as links.
|
||||
<!-- Basic Syntax -->
|
||||
@[qrcode](text)
|
||||
<!-- With Attributes -->
|
||||
@[qrcode card svg title="xxx" align="center"](text)
|
||||
@[qrcode card title="xxx" align="center" logo="/plume.png"](text)
|
||||
```
|
||||
|
||||
### Container Syntax
|
||||
@ -50,7 +50,7 @@ Inline syntax is suitable for shorter `text`, such as links.
|
||||
Container syntax is suitable for longer `text`, such as paragraphs or multi-line text.
|
||||
|
||||
```md
|
||||
::: qrcode card svg title="xxx" align="center"
|
||||
::: qrcode card title="xxx" align="center"
|
||||
text
|
||||
:::
|
||||
```
|
||||
@ -64,8 +64,13 @@ text
|
||||
::: field name="card" type="boolean" optional default="false"
|
||||
Whether to enable the card style.
|
||||
:::
|
||||
::: field name="svg" type="boolean" optional default="false"
|
||||
Whether to render the QR code in SVG format. The default format is PNG.
|
||||
::: field name="logo" type="string" optional
|
||||
The path to the logo image displayed at the center of the QR code.
|
||||
|
||||
Only absolute paths are supported.
|
||||
:::
|
||||
::: field name="logoSize" type="number" optional default="0.2"
|
||||
The size ratio of the logo relative to the QR code.
|
||||
:::
|
||||
::: field name="title" type="string" optional
|
||||
The title of the QR code.
|
||||
@ -100,6 +105,8 @@ Four levels are available depending on the operating environment.
|
||||
Higher levels provide better error resistance but reduce the data capacity of the symbol.
|
||||
|
||||
If the QR code symbol is unlikely to be damaged, lower error correction levels like Low or Medium can be safely used.
|
||||
|
||||
When the QR code contains a logo, the default value is `H`.
|
||||
:::
|
||||
::: field name="version" type="number" optional
|
||||
**QR Code Version**
|
||||
@ -129,8 +136,23 @@ If not specified, a more suitable value will be automatically calculated.
|
||||
|
||||
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
### QR Code with Logo
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
```
|
||||
|
||||
**Output:**
|
||||
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
### Internal Page Path
|
||||
|
||||
::: tip Internal page links will automatically have a logo added
|
||||
:::
|
||||
|
||||
**Input:**
|
||||
|
||||
```md
|
||||
|
||||
460
docs/guide/markdown/obsidian.md
Normal file
460
docs/guide/markdown/obsidian.md
Normal file
@ -0,0 +1,460 @@
|
||||
---
|
||||
title: Obsidian 兼容
|
||||
icon: simple-icons:obsidian
|
||||
createTime: 2026/04/17 21:56:55
|
||||
permalink: /guide/markdown/obsidian/
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
主题通过 `vuepress-plugin-md-power` 插件提供对 Obsidian 官方 Markdown 扩展语法的兼容性支持,使 Obsidian 用户能够以熟悉的语法撰写文档。
|
||||
|
||||
当前已支持的 Obsidian 扩展语法包括:
|
||||
|
||||
- [Wiki 链接](#wiki-链接) - 页面间相互链接的语法
|
||||
- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面
|
||||
- [Callout](#callout) - 使用样式容器突出显示重要信息
|
||||
- [注释](#注释) - 添加仅在编辑时可见的注释
|
||||
|
||||
::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法
|
||||
:::
|
||||
|
||||
## 配置
|
||||
|
||||
Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用:
|
||||
|
||||
```ts title=".vuepress/config.ts"
|
||||
export default defineUserConfig({
|
||||
theme: plumeTheme({
|
||||
plugins: {
|
||||
mdPower: {
|
||||
obsidian: {
|
||||
wikiLink: true, // Wiki 链接
|
||||
embedLink: true, // 嵌入内容
|
||||
callout: true, // Callout
|
||||
comment: true, // 注释
|
||||
},
|
||||
pdf: true, // PDF 嵌入功能
|
||||
artPlayer: true, // 视频嵌入功能
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 配置项
|
||||
|
||||
:::: field-group
|
||||
|
||||
::: field name="wikiLink" type="boolean" default="true" optional
|
||||
启用 [Wiki 链接](#wiki-链接) 语法。
|
||||
:::
|
||||
|
||||
::: field name="embedLink" type="boolean" default="true" optional
|
||||
启用 [嵌入内容](#嵌入内容) 语法。
|
||||
:::
|
||||
|
||||
::: field name="callout" type="boolean" default="true" optional
|
||||
启用 [Callout](#callout) 语法。
|
||||
:::
|
||||
|
||||
::: field name="comment" type="boolean" default="true" optional
|
||||
启用 [注释](#注释) 语法。
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## Wiki 链接
|
||||
|
||||
Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。
|
||||
|
||||
### 语法
|
||||
|
||||
```md
|
||||
[[文件名]]
|
||||
[[文件名#标题]]
|
||||
[[文件名#标题#子标题]]
|
||||
[[文件名|别名]]
|
||||
[[文件名#标题|别名]]
|
||||
[[https://example.com|外部链接]]
|
||||
```
|
||||
|
||||
### 文件名搜索规则
|
||||
|
||||
当使用 Wiki 链接时,文件名会按照以下规则进行搜索匹配:
|
||||
|
||||
**匹配优先级:**
|
||||
|
||||
1. **完整路径** - 精确匹配文件路径
|
||||
2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径
|
||||
|
||||
**路径解析规则:**
|
||||
|
||||
- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析
|
||||
- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径
|
||||
- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md`
|
||||
|
||||
**示例:**
|
||||
|
||||
假设文档结构如下:
|
||||
|
||||
```txt
|
||||
docs/
|
||||
├── README.md
|
||||
├── guide/
|
||||
│ ├── README.md
|
||||
│ └── markdown/
|
||||
│ └── obsidian.md
|
||||
```
|
||||
|
||||
在 `docs/guide/markdown/obsidian.md` 中:
|
||||
|
||||
| 语法 | 匹配结果 |
|
||||
| -------------- | -------------------------------------------------------- |
|
||||
| `[[obsidian]]` | 匹配 `docs/guide/markdown/obsidian.md`(通过文件名检索) |
|
||||
| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) |
|
||||
| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) |
|
||||
| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) |
|
||||
|
||||
### 示例
|
||||
|
||||
**外部链接:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
[[https://example.com|外部链接]]
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
[[https://example.com|外部链接]]
|
||||
|
||||
**内部锚点链接:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
[[npm-to]] <!-- 通过文件名检索 -->
|
||||
[[guide/markdown/math]] <!-- 通过文件路径检索-->
|
||||
[[#Wiki 链接]] <!-- 当前页面使用 heading -->
|
||||
[[file-tree#配置]] <!-- 通过文件名检索,并链接到 heading -->
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
[[npm-to]]
|
||||
|
||||
[[guide/markdown/math]]
|
||||
|
||||
[[#Wiki 链接]]
|
||||
|
||||
[[file-tree#配置]]
|
||||
|
||||
[Obsidian 官方 - **Wiki Links**](https://obsidian.md/zh/help/links){.readmore}
|
||||
|
||||
## 嵌入内容
|
||||
|
||||
嵌入语法允许你将其他文件资源插入到当前页面中。
|
||||
|
||||
### 语法
|
||||
|
||||
```md
|
||||
![[文件名]]
|
||||
![[文件名#标题]]
|
||||
![[文件名#标题#子标题]]
|
||||
```
|
||||
|
||||
文件名搜索规则与 [Wiki 链接](#文件名搜索规则) 相同。
|
||||
|
||||
::: info 以 `/` 开头或 无路径前缀如 `./` 形式的,从 `public` 目录中加载资源
|
||||
:::
|
||||
|
||||
### 图片嵌入
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[图片]]
|
||||
![[图片|宽度]]
|
||||
![[图片|宽度x高度]]
|
||||
```
|
||||
|
||||
支持格式:`jpg`、`jpeg`、`png`、`gif`、`avif`、`webp`、`svg`、`bmp`、`ico`、`tiff`、`apng`、`jfif`、`pjpeg`、`pjp`、`xbm`
|
||||
|
||||
**示例:**
|
||||
|
||||
::: demo markdown title="基础图片" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: demo markdown title="设置宽度" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg|300]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::: demo markdown title="设置宽度和高度" expanded
|
||||
|
||||
```md
|
||||
![[images/custom-hero.jpg|300x200]]
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### PDF 嵌入
|
||||
|
||||
> [!NOTE]
|
||||
> PDF 嵌入需要启用 `markdown.pdf` 插件才能正常工作。
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[文档.pdf]]
|
||||
![[文档.pdf#page=1]] <!-- #page=1 表示第一页 -->
|
||||
![[文档.pdf#page=1#height=300]] <!-- #page=页码 #height=高度 -->
|
||||
```
|
||||
|
||||
支持格式:`pdf`
|
||||
|
||||
---
|
||||
|
||||
### 音频嵌入
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[音频文件]]
|
||||
```
|
||||
|
||||
支持格式:`mp3`、`flac`、`wav`、`ogg`、`opus`、`webm`、`acc`
|
||||
|
||||
---
|
||||
|
||||
### 视频嵌入
|
||||
|
||||
> [!NOTE]
|
||||
> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。
|
||||
|
||||
**语法:**
|
||||
|
||||
```md
|
||||
![[视频文件]]
|
||||
![[视频文件#height=400]] <!-- 设置视频高度 -->
|
||||
```
|
||||
|
||||
支持格式:`mp4`、`webm`、`mov` 等
|
||||
|
||||
---
|
||||
|
||||
### 内容片段嵌入
|
||||
|
||||
通过 `#标题` 可以嵌入指定标题下的内容片段:
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
![[我的笔记]]
|
||||
![[我的笔记#标题一]]
|
||||
![[我的笔记#标题一#子标题]]
|
||||
```
|
||||
|
||||
[Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore}
|
||||
[Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore}
|
||||
|
||||
## Callout
|
||||
|
||||
Callout 是一种用于突出显示重要信息的语法,类似于 VuePress 的 `::: hint` 提示框语法。
|
||||
|
||||
### 语法
|
||||
|
||||
```md
|
||||
> [!note]
|
||||
> 内容
|
||||
```
|
||||
|
||||
**可选标题:**
|
||||
|
||||
```md
|
||||
> [!tip] 自定义标题
|
||||
> 内容
|
||||
```
|
||||
|
||||
### 类型
|
||||
|
||||
Callout 支持以下类型,别名会自动映射到对应的主要类型:
|
||||
|
||||
| 类型 | 别名 | 说明 |
|
||||
| ---- | ---- | ---- |
|
||||
| `note` | `quote`, `cite` | 笔记、引用 |
|
||||
| `tip` | `hint` | 技巧、提示 |
|
||||
| `info` | `todo` | 信息、待办 |
|
||||
| `success` | `check`, `done` | 成功、完成 |
|
||||
| `warning` | `question`, `help`, `faq` | 警告、问题、帮助 |
|
||||
| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | 注意、失败、危险 |
|
||||
| `important` | `example` | 重要、示例 |
|
||||
| `details` | `abstract`, `summary`, `tldr` | 详情、摘要 |
|
||||
|
||||
### 示例
|
||||
|
||||
**基础用法:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
> [!NOTE]
|
||||
> 这是一个笔记提示框。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
> [!NOTE]
|
||||
> 这是一个笔记提示框。
|
||||
|
||||
---
|
||||
|
||||
**带标题:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
> [!TIP] 实用技巧
|
||||
> 使用 `pnpm` 可以显著加快依赖安装速度。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
> [!TIP] 实用技巧
|
||||
> 使用 `pnpm` 可以显著加快依赖安装速度。
|
||||
|
||||
---
|
||||
|
||||
**多种类型:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
> [!success]
|
||||
> 操作成功完成!
|
||||
>
|
||||
> [!warning]
|
||||
> 这是一个警告信息。
|
||||
>
|
||||
> [!caution]
|
||||
> 请谨慎操作,此操作不可撤销。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
> [!success]
|
||||
> 操作成功完成!
|
||||
|
||||
> [!warning]
|
||||
> 这是一个警告信息。
|
||||
|
||||
> [!caution]
|
||||
> 请谨慎操作,此操作不可撤销。
|
||||
|
||||
---
|
||||
|
||||
**Details 类型:**
|
||||
|
||||
`details` 类型会渲染为 HTML `<details>` 元素,支持折叠展开:
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
> [!details]
|
||||
> 点我展开更多内容
|
||||
>
|
||||
> 这是一段隐藏的内容。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
> [!details]
|
||||
> 点我展开更多内容
|
||||
>
|
||||
> 这是一段隐藏的内容。
|
||||
|
||||
[Obsidian 官方 - Callout](https://obsidian.md/zh/help/callouts){.readmore}
|
||||
|
||||
## 注释
|
||||
|
||||
使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。
|
||||
|
||||
### 语法
|
||||
|
||||
**行内注释:**
|
||||
|
||||
```md
|
||||
这是一个 %%行内注释%% 示例。
|
||||
```
|
||||
|
||||
**块级注释:**
|
||||
|
||||
```md
|
||||
%%
|
||||
这是一个块级注释。
|
||||
可以跨越多行。
|
||||
%%
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
**行内注释:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
这是一个 %%行内注释%% 示例。
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
这是一个 %%行内注释%% 示例。
|
||||
|
||||
---
|
||||
|
||||
**块级注释:**
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
注释之前的内容
|
||||
|
||||
%%
|
||||
这是一个块级注释。
|
||||
|
||||
可以跨越多行。
|
||||
%%
|
||||
|
||||
注释之后的内容
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
注释之前的内容
|
||||
|
||||
%%
|
||||
这是一个块级注释。
|
||||
%%
|
||||
|
||||
可以跨越多行。
|
||||
|
||||
[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore}
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能
|
||||
- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内
|
||||
- 嵌入内容时,被嵌入的页面也会参与主题的构建过程
|
||||
- PDF 嵌入需要同时启用 `markdown.pdf` 插件
|
||||
- 视频嵌入需要同时启用 `markdown.artPlayer` 插件
|
||||
- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载
|
||||
@ -6,7 +6,7 @@ permalink: /guide/markdown/qrcode/
|
||||
badge: 新
|
||||
---
|
||||
|
||||
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
## 概述
|
||||
|
||||
@ -42,7 +42,7 @@ export default defineUserConfig({
|
||||
<!-- 基础语法-->
|
||||
@[qrcode](text)
|
||||
<!-- 添加属性 -->
|
||||
@[qrcode card svg title="xxx" align="center"](text)
|
||||
@[qrcode card title="xxx" align="center" logo="/plume.png"](text)
|
||||
```
|
||||
|
||||
### 容器语法
|
||||
@ -50,7 +50,7 @@ export default defineUserConfig({
|
||||
容器语法适用于 `text` 文本较长时,比如 段落,多行文本 等。
|
||||
|
||||
```md
|
||||
::: qrcode card svg title="xxx" align="center"
|
||||
::: qrcode card title="xxx" align="center"
|
||||
text
|
||||
:::
|
||||
```
|
||||
@ -64,8 +64,13 @@ text
|
||||
::: field name="card" type="boolean" optional default="false"
|
||||
是否启用卡片样式。
|
||||
:::
|
||||
::: field name="svg" type="boolean" optional default="false"
|
||||
是否将二维码渲染为 SVG 格式。默认渲染为 PNG 格式。
|
||||
::: field name="logo" type="string" optional
|
||||
二维码 logo 图片路径。显示于二维码中心。
|
||||
|
||||
仅支持 绝对路径。
|
||||
:::
|
||||
::: field name="logoSize" type="number" optional default="0.2"
|
||||
logo 相对于二维码 大小比例
|
||||
:::
|
||||
::: field name="title" type="string" optional
|
||||
二维码标题。
|
||||
@ -98,6 +103,8 @@ text
|
||||
更高级别提供更好的抗错能力,但会降低符号的容量。
|
||||
|
||||
如果二维码符号可能被损坏的几率较低,则可以安全使用低纠错级别,如低或中。
|
||||
|
||||
当二维码中包含 logo 时,默认值为 `H`。
|
||||
:::
|
||||
::: field name="version" type="number" optional
|
||||
**二维码版本**
|
||||
@ -127,8 +134,23 @@ text
|
||||
|
||||
@[qrcode](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
### 带 logo 的二维码
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
```
|
||||
|
||||
**输出:**
|
||||
|
||||
@[qrcode logo="/plume.png" logo-size=0.25](https://github.com/pengzhanbo/vuepress-theme-plume)
|
||||
|
||||
### 站内的页面路径
|
||||
|
||||
::: tip 站内页面链接自动添加 logo 图片
|
||||
:::
|
||||
|
||||
**输入:**
|
||||
|
||||
```md
|
||||
|
||||
@ -99,6 +99,7 @@ search: false
|
||||
| *纪 | 2026-01-03 | 9.90 | 新年快乐(,,>‿<,,),感谢佬 |
|
||||
| J*n | 2026-01-22 | 10.00 | 用本开源主题搭了好几个网站了,作者耐心解答,添加合理功能需求,必须支持一下,辛苦了❤️ |
|
||||
| *燧 | 2026-03-14 | 8.88 | 智齿主播,大佬加油 <br>(作者回复:啊?主播?我不是啊) |
|
||||
| *飞 | 2026-04-25 | 9.90 | - |
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/themes/*": ["./.vuepress/themes/*"],
|
||||
"~/components/*": ["./.vuepress/themes/components/*"],
|
||||
|
||||
@ -10,6 +10,8 @@ export default config({
|
||||
'skills',
|
||||
'docs/snippet/code-block.snippet.md',
|
||||
'docs/snippet/whitespace.snippet.md',
|
||||
'docs/en/guide/markdown/obsidian.md',
|
||||
'docs/guide/markdown/obsidian.md',
|
||||
],
|
||||
globals: {
|
||||
__VUEPRESS_VERSION__: 'readonly',
|
||||
|
||||
19
package.json
19
package.json
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "vuepress-theme-plume-monorepo",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
@ -33,7 +33,7 @@
|
||||
"lint:css": "stylelint **/*.{css,vue}",
|
||||
"test": "cross-env TZ=Etc/UTC vitest --coverage",
|
||||
"prepare": "husky",
|
||||
"release:changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
||||
"release:changelog": "conventional-changelog -p angular",
|
||||
"release:check": "pnpm lint && pnpm build",
|
||||
"release:sync": "node scripts/mirror-sync.mjs",
|
||||
"release:publish": "pnpm -r publish --tag latest",
|
||||
@ -57,7 +57,8 @@
|
||||
"@vitest/coverage-v8": "catalog:dev",
|
||||
"bumpp": "catalog:dev",
|
||||
"commitizen": "catalog:dev",
|
||||
"conventional-changelog-cli": "catalog:dev",
|
||||
"conventional-changelog": "catalog:dev",
|
||||
"conventional-changelog-angular": "catalog:dev",
|
||||
"cpx2": "catalog:dev",
|
||||
"cross-env": "catalog:dev",
|
||||
"cz-conventional-changelog": "catalog:dev",
|
||||
@ -85,17 +86,23 @@
|
||||
}
|
||||
},
|
||||
"resolutions": {
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@eslint-community/eslint-utils": "catalog:peer",
|
||||
"@shikijs/core": "^4.0.2",
|
||||
"@shikijs/twoslash": "^4.0.2",
|
||||
"@typescript-eslint/types": "catalog:peer",
|
||||
"@typescript-eslint/utils": "catalog:peer",
|
||||
"baseline-browser-mapping": "^2.10.13",
|
||||
"@xmldom/xmldom": ">=0.9.10",
|
||||
"baseline-browser-mapping": "^2.10.22",
|
||||
"chokidar": "catalog:prod",
|
||||
"dompurify": ">=3.4.1",
|
||||
"esbuild": "catalog:prod",
|
||||
"follow-redirects": ">=1.16.0",
|
||||
"lodash": ">=4.18.1",
|
||||
"lodash-es": ">=4.18.1",
|
||||
"sass-embedded": "catalog:peer",
|
||||
"shiki": "^4.0.2",
|
||||
"tmp": ">=0.2.5",
|
||||
"vite": "catalog:dev",
|
||||
"vue-router": "catalog:prod"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vuepress-plume/plugin-fonts",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"description": "The Plugin for VuePress 2 - fonts",
|
||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
917
plugins/plugin-md-power/__test__/obsidianCallouts.spec.ts
Normal file
917
plugins/plugin-md-power/__test__/obsidianCallouts.spec.ts
Normal file
@ -0,0 +1,917 @@
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calloutPlugin } from '../src/node/obsidian/callouts.js'
|
||||
|
||||
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
|
||||
return {
|
||||
filePathRelative,
|
||||
base: '/',
|
||||
links: [],
|
||||
importedFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createMarkdown() {
|
||||
return new MarkdownIt({ html: true })
|
||||
}
|
||||
|
||||
describe('calloutPlugin', () => {
|
||||
// ==================== Primary Callout Types ====================
|
||||
|
||||
describe('primary callout types', () => {
|
||||
const types = ['note', 'tip', 'info', 'success', 'warning', 'caution', 'important', 'details']
|
||||
|
||||
types.forEach((type) => {
|
||||
it(`should render ${type} callout`, () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
// Callout format: >[!type] title on same line, content on continuation lines with >
|
||||
const result = md.render(`>[!${type}]\n>\n> Content here.`)
|
||||
|
||||
expect(result).toContain(`hint-container ${type}`)
|
||||
expect(result).toContain('Content here')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render note with quote and cite aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
|
||||
const quoteResult = md.render('>[!quote]\n>\n> Content.')
|
||||
expect(quoteResult).toContain('hint-container note')
|
||||
|
||||
const citeResult = md.render('>[!cite]\n>\n> Content.')
|
||||
expect(citeResult).toContain('hint-container note')
|
||||
})
|
||||
|
||||
it('should render tip with hint alias', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!hint]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should render info with todo alias', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!todo]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container info')
|
||||
})
|
||||
|
||||
it('should render success with check and done aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
|
||||
const checkResult = md.render('>[!check]\n>\n> Content.')
|
||||
expect(checkResult).toContain('hint-container success')
|
||||
|
||||
const doneResult = md.render('>[!done]\n>\n> Content.')
|
||||
expect(doneResult).toContain('hint-container success')
|
||||
})
|
||||
|
||||
it('should render warning with question, help, and faq aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
|
||||
const questionResult = md.render('>[!question]\n>\n> Content.')
|
||||
expect(questionResult).toContain('hint-container warning')
|
||||
|
||||
const helpResult = md.render('>[!help]\n>\n> Content.')
|
||||
expect(helpResult).toContain('hint-container warning')
|
||||
|
||||
const faqResult = md.render('>[!faq]\n>\n> Content.')
|
||||
expect(faqResult).toContain('hint-container warning')
|
||||
})
|
||||
|
||||
it('should render caution with multiple aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const aliases = ['attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug']
|
||||
|
||||
aliases.forEach((alias) => {
|
||||
const result = md.render(`>[!${alias}]\n>\n> Content.`)
|
||||
expect(result).toContain('hint-container caution')
|
||||
})
|
||||
})
|
||||
|
||||
it('should render important with example alias', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!example]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container important')
|
||||
})
|
||||
|
||||
it('should render details with abstract, summary, and tldr aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
|
||||
const abstractResult = md.render('>[!abstract]\n>\n> Content.')
|
||||
expect(abstractResult).toContain('hint-container details')
|
||||
|
||||
const summaryResult = md.render('>[!summary]\n>\n> Content.')
|
||||
expect(summaryResult).toContain('hint-container details')
|
||||
|
||||
const tldrResult = md.render('>[!tldr]\n>\n> Content.')
|
||||
expect(tldrResult).toContain('hint-container details')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Case Insensitivity ====================
|
||||
|
||||
describe('case insensitivity', () => {
|
||||
it('should handle uppercase type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!TIP]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should handle mixed case type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!Tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should handle lowercase type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Title Handling ====================
|
||||
|
||||
describe('title handling', () => {
|
||||
it('should render custom title text', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] Custom Title\n>\n> Content.')
|
||||
expect(result).toContain('Custom Title')
|
||||
})
|
||||
|
||||
it('should render title with + prefix', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] + Custom Title\n>\n> Content.')
|
||||
expect(result).toContain('Custom Title')
|
||||
})
|
||||
|
||||
it('should render title with - prefix', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] - Custom Title\n>\n> Content.')
|
||||
expect(result).toContain('Custom Title')
|
||||
})
|
||||
|
||||
it('should use default capitalized type when no title', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('Tip')
|
||||
})
|
||||
|
||||
it('should render empty title with default', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] \n>\n> Content.')
|
||||
expect(result).toContain('Tip')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Content Rendering ====================
|
||||
|
||||
describe('content rendering', () => {
|
||||
it('should render single line content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Single line content.')
|
||||
expect(result).toContain('Single line content')
|
||||
})
|
||||
|
||||
it('should render multi-line content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> First paragraph.
|
||||
>
|
||||
> Second paragraph.`)
|
||||
expect(result).toContain('First paragraph')
|
||||
expect(result).toContain('Second paragraph')
|
||||
})
|
||||
|
||||
it('should render nested list within callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> - Item 1
|
||||
> - Item 2
|
||||
> - Item 3`)
|
||||
expect(result).toContain('Item 1')
|
||||
expect(result).toContain('Item 2')
|
||||
expect(result).toContain('Item 3')
|
||||
})
|
||||
|
||||
it('should render heading within callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> ### Nested Heading
|
||||
>
|
||||
> Content after heading.`)
|
||||
expect(result).toContain('Nested Heading')
|
||||
expect(result).toContain('Content after heading')
|
||||
})
|
||||
|
||||
it('should render code block within callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> \`\`\`js
|
||||
> const x = 1;
|
||||
> \`\`\``)
|
||||
expect(result).toContain('const x = 1')
|
||||
})
|
||||
|
||||
it('should parse content after callout correctly', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Callout content.
|
||||
>
|
||||
After callout paragraph.
|
||||
|
||||
## Heading after
|
||||
|
||||
More content.`)
|
||||
|
||||
expect(result).toContain('Callout content')
|
||||
expect(result).toContain('After callout paragraph')
|
||||
expect(result).toContain('Heading after')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Syntax Variations ====================
|
||||
|
||||
describe('syntax variations', () => {
|
||||
it('should parse without space after >', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should parse with space after >', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('> [!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should parse with multiple spaces', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('> [!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should parse with tab after >', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>\t[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should handle tab with space alignment', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(' >\t [!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Block Parsing Edge Cases ====================
|
||||
|
||||
describe('block parsing edge cases', () => {
|
||||
it('should terminate on empty line outside callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content.
|
||||
>
|
||||
> More content.
|
||||
|
||||
After callout.`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('More content')
|
||||
expect(result).toContain('After callout')
|
||||
})
|
||||
|
||||
it('should terminate on outdented content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content.
|
||||
outdented line`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('outdented line')
|
||||
})
|
||||
|
||||
it('should handle outdented line as block terminator (line 265)', () => {
|
||||
// When the content line is outdented (sCount < blkIndent), the callout ends
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. List item
|
||||
>[!tip]
|
||||
>
|
||||
> Content.
|
||||
|
||||
2. Next item`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('Next item')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should terminate on horizontal rule', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
// Using *** instead of - - - to ensure it's recognized as horizontal rule
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content.
|
||||
>
|
||||
> ***`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
|
||||
it('should terminate when terminator rule matches (lines 280-281)', () => {
|
||||
// The terminator rule for blockquote will match when the callout is properly terminated
|
||||
// by another blockquote-like structure
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
// After the horizontal rule, the content below should be separate
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content.
|
||||
|
||||
---
|
||||
|
||||
After horizontal rule.`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('After horizontal rule')
|
||||
})
|
||||
|
||||
it('should handle list terminator correctly (lines 280-281)', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Callout content.
|
||||
>
|
||||
> 1. Ordered list inside`)
|
||||
|
||||
expect(result).toContain('Callout content')
|
||||
expect(result).toContain('Ordered list inside')
|
||||
})
|
||||
|
||||
it('should terminate on list item', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content.
|
||||
>
|
||||
> 1. Ordered item`)
|
||||
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
|
||||
it('should handle continuation after blockquote in list', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. List item
|
||||
>[!tip]
|
||||
>
|
||||
> Content in callout.
|
||||
>
|
||||
> More content.
|
||||
|
||||
2. Next list item`)
|
||||
|
||||
expect(result).toContain('Content in callout')
|
||||
expect(result).toContain('More content')
|
||||
expect(result).toContain('Next list item')
|
||||
})
|
||||
|
||||
it('should restore state correctly when blkIndent !== 0 (lines 290-304)', () => {
|
||||
// When callout is in a list item (non-zero blkIndent) and is terminated
|
||||
// by another block, the blkIndent adjustment should be restored
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. List item
|
||||
>[!tip]
|
||||
>
|
||||
> Callout content.
|
||||
>
|
||||
> ---`)
|
||||
|
||||
expect(result).toContain('Callout content')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should handle nested blockquote in callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> > Nested quote
|
||||
>
|
||||
> Content after nested.`)
|
||||
expect(result).toContain('Nested quote')
|
||||
expect(result).toContain('Content after nested')
|
||||
})
|
||||
|
||||
it('should handle callout with proper terminator restoration', () => {
|
||||
// Test for lines 290-304: blkIndent restoration when terminated by other block
|
||||
// This requires the callout to be inside a list with non-zero blkIndent
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. List item
|
||||
>[!tip]
|
||||
>
|
||||
> Callout content.
|
||||
>
|
||||
> More content.
|
||||
>
|
||||
> - - -
|
||||
|
||||
2. Next item`)
|
||||
|
||||
expect(result).toContain('Callout content')
|
||||
expect(result).toContain('More content')
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Next item')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Invalid Syntax ====================
|
||||
|
||||
describe('invalid syntax', () => {
|
||||
it('should not parse unknown type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!unknown]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
|
||||
it('should not parse empty type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse incomplete syntax without closing bracket', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse without opening bracket', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>!tip]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse without > marker', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('[!tip]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse when indented more than 3 spaces (becomes code)', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(' >[!tip]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
expect(result).toContain('<code')
|
||||
})
|
||||
|
||||
it('should return false when sCount - blkIndent >= 4 (line 44)', () => {
|
||||
// Line 44: if sCount - blkIndent >= 4, return false
|
||||
// This would happen when a line is deeply indented beyond the block indent
|
||||
// In practice, blkIndent tracks sCount in list contexts, making this hard to trigger
|
||||
// We test with a deeply indented block that exceeds normal block processing
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
// This scenario exercises the code path even if the exact condition is hard to isolate
|
||||
const result = md.render('> [!tip]\n>\n> Content.')
|
||||
// With 5 spaces after >, offset-initial=4 triggers line 96 first
|
||||
// But the overall block parsing exercises related code paths
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse type only without brackets', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>tip]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse empty callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should not parse callout with only empty continuation lines', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
>`)
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should return false when offset - initial >= 4 (line 96)', () => {
|
||||
// Line 96: offset - initial >= 4 means 4+ spaces after > before the callout type
|
||||
// > [!tip] has 5 spaces after >, so offset - initial = 4 >= 4, returns false
|
||||
// This causes it to be treated as a code block within blockquote
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('> [!tip]\n>\n> Content.')
|
||||
expect(result).not.toContain('hint-container')
|
||||
// It should be treated as blockquote with code-like content
|
||||
expect(result).toContain('<code')
|
||||
expect(result).toContain('[!tip]')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Special Type: details ====================
|
||||
|
||||
describe('details type rendering', () => {
|
||||
it('should render details as details element', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!details]\n>\n> Content.')
|
||||
expect(result).toContain('<details')
|
||||
expect(result).toContain('</details>')
|
||||
expect(result).toContain('<summary')
|
||||
expect(result).toContain('</summary>')
|
||||
})
|
||||
|
||||
it('should not use div for details type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!details]\n>\n> Content.')
|
||||
expect(result).not.toContain('<div class="hint-container details"')
|
||||
})
|
||||
|
||||
it('should render summary tag for details title', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!details] Summary Title\n>\n> Content.')
|
||||
expect(result).toContain('<summary')
|
||||
expect(result).toContain('Summary Title')
|
||||
})
|
||||
|
||||
it('should render details with other aliases', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!abstract]\n>\n> Content.')
|
||||
expect(result).toContain('<details')
|
||||
expect(result).toContain('<summary')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Default Rendering Structure ====================
|
||||
|
||||
describe('rendering structure', () => {
|
||||
it('should render alert_open with correct class', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container tip')
|
||||
})
|
||||
|
||||
it('should render alert_title with correct class', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] Title\n>\n> Content.')
|
||||
expect(result).toContain('hint-container-title')
|
||||
})
|
||||
|
||||
it('should render opening and closing tags', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('<div')
|
||||
expect(result).toContain('</div>')
|
||||
})
|
||||
|
||||
it('should render hint-container class', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('hint-container')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Locale Support ====================
|
||||
|
||||
describe('locale support', () => {
|
||||
it('should use default type name when no locale match', () => {
|
||||
const md = createMarkdown().use(calloutPlugin, {
|
||||
locales: {},
|
||||
})
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
expect(result).toContain('Tip')
|
||||
})
|
||||
|
||||
it('should use custom locale title when provided', () => {
|
||||
const md = createMarkdown().use(calloutPlugin, {
|
||||
locales: {
|
||||
'/': {
|
||||
tip: 'Custom Tip Title',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('/'))
|
||||
expect(result).toContain('Custom Tip Title')
|
||||
})
|
||||
|
||||
it('should use locale for specific path', () => {
|
||||
const md = createMarkdown().use(calloutPlugin, {
|
||||
locales: {
|
||||
'/zh/': {
|
||||
tip: '提示',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md'))
|
||||
expect(result).toContain('提示')
|
||||
})
|
||||
|
||||
it('should prefer custom locale over default', () => {
|
||||
const md = createMarkdown().use(calloutPlugin, {
|
||||
locales: {
|
||||
'/': {
|
||||
tip: 'Default Tip',
|
||||
},
|
||||
'/zh/': {
|
||||
tip: '中文提示',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const defaultResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('guide.md'))
|
||||
expect(defaultResult).toContain('Default Tip')
|
||||
|
||||
const zhResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md'))
|
||||
expect(zhResult).toContain('中文提示')
|
||||
})
|
||||
|
||||
it('should handle locale without matching type', () => {
|
||||
const md = createMarkdown().use(calloutPlugin, {
|
||||
locales: {
|
||||
'/': {
|
||||
note: 'Note Title',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = md.render('>[!tip]\n>\n> Content.')
|
||||
// Should still use capitalized type as fallback
|
||||
expect(result).toContain('Tip')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle callout at beginning of document', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> First content.
|
||||
|
||||
Second paragraph.`)
|
||||
expect(result).toContain('First content')
|
||||
expect(result).toContain('Second paragraph')
|
||||
})
|
||||
|
||||
it('should handle multiple callouts in sequence', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Tip content.
|
||||
|
||||
>[!warning]
|
||||
>
|
||||
> Warning content.
|
||||
|
||||
>[!note]
|
||||
>
|
||||
> Note content.`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Tip content')
|
||||
expect(result).toContain('hint-container warning')
|
||||
expect(result).toContain('Warning content')
|
||||
expect(result).toContain('hint-container note')
|
||||
expect(result).toContain('Note content')
|
||||
})
|
||||
|
||||
it('should handle empty lines within callout', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Line 1.
|
||||
>
|
||||
>
|
||||
> Line 2.`)
|
||||
|
||||
expect(result).toContain('Line 1')
|
||||
expect(result).toContain('Line 2')
|
||||
})
|
||||
|
||||
it('should handle adjacent callouts without blank line', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> First.
|
||||
>[!note]
|
||||
>
|
||||
> Second.`)
|
||||
|
||||
expect(result).toContain('First')
|
||||
expect(result).toContain('Second')
|
||||
})
|
||||
|
||||
it('should not interfere with regular blockquotes', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`> Regular blockquote
|
||||
>
|
||||
> Another line.`)
|
||||
|
||||
expect(result).not.toContain('hint-container')
|
||||
expect(result).toContain('Regular blockquote')
|
||||
})
|
||||
|
||||
it('should handle indented callout in list', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`- List item
|
||||
>[!tip]
|
||||
>
|
||||
> Indented callout.`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Indented callout')
|
||||
})
|
||||
|
||||
it('should handle unicode in title', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] 中文标题\n>\n> Content.')
|
||||
expect(result).toContain('中文标题')
|
||||
})
|
||||
|
||||
it('should handle emoji in title', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip] 🚀 Launch\n>\n> Content.')
|
||||
expect(result).toContain('🚀')
|
||||
expect(result).toContain('Launch')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Inline Content Rendering ====================
|
||||
|
||||
describe('inline content rendering', () => {
|
||||
it('should render inline code in content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Use `code` inline.')
|
||||
expect(result).toContain('<code>code</code>')
|
||||
})
|
||||
|
||||
it('should render links in content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> Check [this](https://example.com).')
|
||||
expect(result).toContain('<a href="https://example.com"')
|
||||
})
|
||||
|
||||
it('should render emphasis in content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> This is *italic* and **bold**.')
|
||||
expect(result).toContain('<em>italic</em>')
|
||||
expect(result).toContain('<strong>bold</strong>')
|
||||
})
|
||||
|
||||
it('should render strikethrough in content', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render('>[!tip]\n>\n> ~~Deleted~~ text.')
|
||||
expect(result).toContain('<s>Deleted</s>')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Code Block Type Detection ====================
|
||||
|
||||
describe('code block type detection', () => {
|
||||
it('should detect as code block when indented 4+ spaces', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(` >[!tip] Title
|
||||
>
|
||||
> Content.`)
|
||||
|
||||
expect(result).toContain('<code')
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sCount - blkIndent >= 4', () => {
|
||||
it('should return false when deeply indented and code rule is disabled', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
md.block.ruler.disable('code')
|
||||
const result = md.render(' >[!tip]\n>\n> Content.')
|
||||
|
||||
expect(result).not.toContain('hint-container')
|
||||
})
|
||||
|
||||
it('should return false when deeply indented inside list with code rule disabled', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
md.block.ruler.disable('code')
|
||||
const result = md.render(`- Item
|
||||
>[!tip]
|
||||
>
|
||||
> Content`)
|
||||
|
||||
expect(result).not.toContain('hint-container tip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOutdented break inside list', () => {
|
||||
it('should break when body line is outdented from list context', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`- Item
|
||||
>[!tip]
|
||||
>
|
||||
> Content
|
||||
Outdented line`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('Outdented line')
|
||||
})
|
||||
|
||||
it('should break when callout body reaches line outside list indent', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. Item
|
||||
>[!warning]
|
||||
>
|
||||
> Content
|
||||
Outside list`)
|
||||
|
||||
expect(result).toContain('hint-container warning')
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('Outside list')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminator rule matches', () => {
|
||||
it('should terminate callout when horizontal rule follows without blank line', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content
|
||||
---`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('<hr')
|
||||
})
|
||||
|
||||
it('should terminate callout when ATX heading follows without blank line', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content
|
||||
## Heading`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('Heading')
|
||||
})
|
||||
|
||||
it('should terminate callout when fence block follows without blank line', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`>[!tip]
|
||||
>
|
||||
> Content
|
||||
\`\`\`js
|
||||
code
|
||||
\`\`\``)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Content')
|
||||
expect(result).toContain('<code')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blkIndent !== 0 when terminated', () => {
|
||||
it('should adjust sCount when callout in list is terminated by hr', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`- Item
|
||||
>[!tip]
|
||||
>
|
||||
> Content
|
||||
---`)
|
||||
|
||||
expect(result).toContain('hint-container tip')
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
|
||||
it('should adjust sCount when callout in ordered list is terminated by hr', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`1. Item
|
||||
>[!caution]
|
||||
>
|
||||
> Content
|
||||
---`)
|
||||
|
||||
expect(result).toContain('hint-container caution')
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
|
||||
it('should handle callout in list terminated by fence', () => {
|
||||
const md = createMarkdown().use(calloutPlugin)
|
||||
const result = md.render(`- Item
|
||||
>[!note]
|
||||
>
|
||||
> Content
|
||||
\`\`\`
|
||||
code
|
||||
\`\`\``)
|
||||
|
||||
expect(result).toContain('hint-container note')
|
||||
expect(result).toContain('Content')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
752
plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts
Normal file
752
plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts
Normal file
@ -0,0 +1,752 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js'
|
||||
import { initPagePaths } from '../src/node/obsidian/findFirstPage.js'
|
||||
|
||||
const mockGlobSync = vi.fn()
|
||||
const mockReadFileSync = vi.fn()
|
||||
|
||||
vi.mock('vuepress/utils', () => ({
|
||||
tinyglobby: {
|
||||
globSync: (...args: unknown[]) => mockGlobSync(...args),
|
||||
},
|
||||
fs: {
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
},
|
||||
path: {
|
||||
dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/') || '.'),
|
||||
extname: vi.fn((p: string) => {
|
||||
const i = p.lastIndexOf('.')
|
||||
return i > 0 ? p.slice(i) : ''
|
||||
}),
|
||||
join: vi.fn((...args: string[]) => args.join('/')),
|
||||
},
|
||||
hash: vi.fn((s: string) => `hash_${s.length}`),
|
||||
}))
|
||||
|
||||
vi.mock('gray-matter', () => ({
|
||||
default: vi.fn((content: string) => ({
|
||||
content: content.replace(/^---[\s\S]*?---\n?/, ''),
|
||||
data: {},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@vuepress/helper', () => ({
|
||||
removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')),
|
||||
}))
|
||||
|
||||
vi.mock('@vuepress/shared', () => ({
|
||||
ensureLeadingSlash: vi.fn((p: string) => (p[0] === '/' ? p : `/${p}`)),
|
||||
isLinkHttp: vi.fn((p: string) => p.startsWith('http://') || p.startsWith('https://')),
|
||||
}))
|
||||
|
||||
vi.mock('../src/node/enhance/links.js', () => ({
|
||||
resolvePaths: vi.fn((rawPath: string) => ({
|
||||
absolutePath: `/${rawPath}`,
|
||||
relativePath: rawPath,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../src/node/utils/slugify.js', () => ({
|
||||
slugify: vi.fn((s: string) => s.toLowerCase().replace(/\s+/g, '-')),
|
||||
}))
|
||||
|
||||
vi.mock('../src/node/utils/cleanMarkdownEnv.js', () => ({
|
||||
cleanMarkdownEnv: vi.fn((env: MarkdownEnv) => env),
|
||||
}))
|
||||
|
||||
function createMockApp(pages: App['pages'] = []): App {
|
||||
return {
|
||||
pages,
|
||||
options: {
|
||||
pagePatterns: ['**/*.md'],
|
||||
},
|
||||
dir: {
|
||||
source: () => '/source',
|
||||
},
|
||||
} as unknown as App
|
||||
}
|
||||
|
||||
function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv {
|
||||
return {
|
||||
filePathRelative,
|
||||
base: '/',
|
||||
links: [],
|
||||
importedFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
function createMarkdownWithMockRules() {
|
||||
return MarkdownIt({ html: true }).use((md) => {
|
||||
md.block.ruler.before('code', 'import_code', () => false)
|
||||
md.renderer.rules.import_code = () => ''
|
||||
})
|
||||
}
|
||||
|
||||
describe('embedLinkPlugin', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReset()
|
||||
mockReadFileSync.mockReset()
|
||||
})
|
||||
|
||||
// ==================== Asset Embedding ====================
|
||||
|
||||
describe('asset embedding', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should render image embed', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[image.png]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('src="/image.png"')
|
||||
expect(result).toContain('alt="image.png"')
|
||||
})
|
||||
|
||||
it('should render image with width setting', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[image.png|300]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 300px')
|
||||
})
|
||||
|
||||
it('should render image with width x height setting', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[image.png|300x200]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 300px')
|
||||
expect(result).toContain('height: 200px')
|
||||
})
|
||||
|
||||
it('should render audio embed', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[audio.mp3]]')
|
||||
expect(result).toContain('<audio')
|
||||
expect(result).toContain('<source')
|
||||
expect(result).toContain('src="/audio.mp3"')
|
||||
})
|
||||
|
||||
it('should render video embed with artPlayer', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[video.mp4]]')
|
||||
expect(result).toContain('<ArtPlayer')
|
||||
expect(result).toContain('src="/video.mp4"')
|
||||
expect(result).toContain('type="mp4"')
|
||||
})
|
||||
|
||||
it('should render pdf embed', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[document.pdf]]')
|
||||
expect(result).toContain('<PDFViewer')
|
||||
expect(result).toContain('src="/document.pdf"')
|
||||
})
|
||||
|
||||
it('should render pdf with page hash', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[document.pdf#page=1]]')
|
||||
expect(result).toContain('page="1"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== External Links ====================
|
||||
|
||||
describe('external links', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should render external http link as anchor', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[https://example.com/file]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="https://example.com/file"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('should return http links as-is for assets', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[https://example.com/image.png]]')
|
||||
expect(result).toContain('src="https://example.com/image.png"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Path Resolution ====================
|
||||
|
||||
describe('path resolution', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should return relative paths starting with dot as-is', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[./image.png]]', createMockEnv('docs/page.md'))
|
||||
expect(result).toContain('src="./image.png"')
|
||||
})
|
||||
|
||||
it('should return absolute paths starting with slash as-is', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[/images/cover.jpg]]')
|
||||
expect(result).toContain('src="/images/cover.jpg"')
|
||||
})
|
||||
|
||||
it('should prepend slash to relative paths without dot', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[image.png]]')
|
||||
expect(result).toContain('src="/image.png"')
|
||||
})
|
||||
|
||||
it('should ignore non-image with unsupported extension as link', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[file.unknown]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="file.unknown"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Markdown Embedding ====================
|
||||
|
||||
describe('markdown file embedding', () => {
|
||||
const guideContent = `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
This is intro content.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Steps for getting started.
|
||||
|
||||
## Advanced
|
||||
|
||||
Advanced content.
|
||||
`
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(guideContent)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
})
|
||||
|
||||
it('should embed entire markdown file when no heading specified', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('![[guide]]', env)
|
||||
|
||||
expect(result).toContain('Introduction')
|
||||
expect(result).toContain('intro content')
|
||||
expect(result).toContain('Getting Started')
|
||||
expect(result).toContain('Steps for getting started')
|
||||
})
|
||||
|
||||
it('should embed content under specific heading', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('![[guide#Getting Started]]', env)
|
||||
|
||||
expect(result).toContain('Steps for getting started')
|
||||
expect(result).not.toContain('Advanced content')
|
||||
expect(result).not.toContain('#') // no heading markers
|
||||
})
|
||||
|
||||
it('should embed nested heading content', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('![[guide#Introduction#Getting Started]]', env)
|
||||
|
||||
expect(result).toContain('Steps for getting started')
|
||||
expect(result).not.toContain('Advanced content')
|
||||
})
|
||||
|
||||
it('should track imported files in env', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
md.render('![[guide]]', env)
|
||||
|
||||
expect(env.importedFiles).toContain('guide.md')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Markdown Not Found ====================
|
||||
|
||||
describe('when page does not exist', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should render markdown file embed as link', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[nonexistent.md]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/nonexistent.md"')
|
||||
})
|
||||
|
||||
it('should render with heading anchor', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[nonexistent#section]]')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/nonexistent#section"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Container Syntax ====================
|
||||
|
||||
describe('container syntax preservation', () => {
|
||||
const contentWithContainers = `---
|
||||
title: Test
|
||||
---
|
||||
|
||||
# Section
|
||||
|
||||
::: info
|
||||
This is a container
|
||||
:::
|
||||
|
||||
Regular content.
|
||||
`
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue(['test.md'])
|
||||
mockReadFileSync.mockReturnValue(contentWithContainers)
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
})
|
||||
|
||||
it('should preserve container syntax when embedding', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('![[test#Section]]', env)
|
||||
|
||||
expect(result).toContain('::: info')
|
||||
expect(result).toContain('This is a container')
|
||||
expect(result).toContain('Regular content')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Error Handling ====================
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty string when file read fails', () => {
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('ENOENT')
|
||||
})
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, app)
|
||||
const env = createMockEnv()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const result = md.render('![[guide]]', env)
|
||||
|
||||
expect(result).toBe('')
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('can not read file'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return empty string when file has only frontmatter', () => {
|
||||
// gray-matter will extract empty content when file has only frontmatter
|
||||
mockGlobSync.mockReturnValue(['empty.md'])
|
||||
mockReadFileSync.mockReturnValue(`---
|
||||
title: Empty
|
||||
---
|
||||
`)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, app)
|
||||
const env = createMockEnv()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const result = md.render('![[empty]]', env)
|
||||
|
||||
expect(result).toBe('')
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('is empty'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Heading Search Edge Cases ====================
|
||||
|
||||
describe('heading search edge cases', () => {
|
||||
it('should find heading when same text appears at different nesting levels', () => {
|
||||
const content = `# Title
|
||||
|
||||
## Summary
|
||||
|
||||
Summary content.
|
||||
|
||||
## Details
|
||||
|
||||
### Summary
|
||||
|
||||
Nested summary under details.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Conclusion content.`
|
||||
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(content)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
// Should match first "Summary" at level 2
|
||||
const result = md.render('![[guide#Summary]]', env)
|
||||
expect(result).toContain('Summary content.')
|
||||
expect(result).not.toContain('Nested summary')
|
||||
})
|
||||
|
||||
it('should return empty string when heading not found', () => {
|
||||
const content = `# Title
|
||||
|
||||
## Section
|
||||
|
||||
Content.`
|
||||
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(content)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const result = md.render('![[guide#Nonexistent]]', env)
|
||||
|
||||
expect(result).toBe('')
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No heading found'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should reset search when encountering same-level heading with different text', () => {
|
||||
const content = `# A
|
||||
|
||||
## B
|
||||
|
||||
B content.
|
||||
|
||||
## C
|
||||
|
||||
C content.`
|
||||
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(content)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
// Searching for A > B > C, but C is at same level as B, not nested under it
|
||||
const result = md.render('![[guide#A#B#C]]', env)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('should reset headingPointer when first heading reappears at shallower level', () => {
|
||||
// Structure: # A, ## B, ## A
|
||||
// When searching for A > B > A, after finding A at level 1 and B at level 2,
|
||||
// we encounter A again at level 2 which is <= currentLevel and matches headings[0]
|
||||
const content = `# A
|
||||
|
||||
## B
|
||||
|
||||
B content.
|
||||
|
||||
## A
|
||||
|
||||
A content again.`
|
||||
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(content)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
// Searching for A > B > A
|
||||
// After finding A (level 1) and B (level 2), we find A at level 2
|
||||
// level 2 <= currentLevel 2 is true, and A === headings[0], so we reset
|
||||
const result = md.render('![[guide#A#B#A]]', env)
|
||||
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Edge Cases ====================
|
||||
|
||||
describe('edge cases', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should not parse embed not ending with ]]', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[image.png]')
|
||||
expect(result).toContain('![[image.png]')
|
||||
})
|
||||
|
||||
it('should not parse empty embed link', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[]]')
|
||||
expect(result).toContain('![[]]')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Inline Embed Link ====================
|
||||
|
||||
describe('inline embed link', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
})
|
||||
|
||||
it('should parse inline image embed within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Here is an image ![[photo.png]] in text.')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('src="/photo.png"')
|
||||
})
|
||||
|
||||
it('should parse inline audio embed within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Listen to ![[music.mp3]] this.')
|
||||
expect(result).toContain('<audio')
|
||||
expect(result).toContain('<source')
|
||||
})
|
||||
|
||||
it('should parse inline video embed within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Watch ![[clip.mp4]] this video.')
|
||||
expect(result).toContain('<ArtPlayer')
|
||||
})
|
||||
|
||||
it('should parse inline pdf embed within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('See ![[doc.pdf]] for details.')
|
||||
expect(result).toContain('<PDFViewer')
|
||||
})
|
||||
|
||||
it('should parse inline external http link within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Check ![[https://example.com/link]] out.')
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="https://example.com/link"')
|
||||
})
|
||||
|
||||
it('should parse inline embed with settings within text', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Image ![[photo.png|400x300]] here.')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 400px')
|
||||
expect(result).toContain('height: 300px')
|
||||
})
|
||||
|
||||
it('should not parse inline embed without closing', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Here ![[image.png] is text.')
|
||||
expect(result).toContain('![[image.png]')
|
||||
})
|
||||
|
||||
it('should handle inline embed with empty content', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('Here ![[]] is text.')
|
||||
// Empty embed is parsed and rendered as an external link
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Inline Markdown Page Embed ====================
|
||||
|
||||
describe('inline markdown page embed (VPLink)', () => {
|
||||
const guideContent = `---
|
||||
title: Guide
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
This is intro content.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Steps for getting started.
|
||||
`
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue(['guide.md'])
|
||||
mockReadFileSync.mockReturnValue(guideContent)
|
||||
|
||||
const app = createMockApp()
|
||||
initPagePaths(app)
|
||||
})
|
||||
|
||||
it('should render inline markdown page embed as VPLink', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('See ![[guide]] for details.', env)
|
||||
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('href="/guide.md"')
|
||||
})
|
||||
|
||||
it('should render inline markdown page embed with anchor as VPLink', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('See ![[guide#Introduction]] for details.', env)
|
||||
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('href="/guide.md#introduction"')
|
||||
})
|
||||
|
||||
it('should render inline markdown page embed with settings as VPLink', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('See ![[guide|Custom Text]] for details.', env)
|
||||
|
||||
expect(result).toContain('<VPLink')
|
||||
expect(result).toContain('Custom Text')
|
||||
})
|
||||
|
||||
it('should render inline markdown page embed with hashes as VPLink using after-text template', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('See ![[guide#Introduction#Getting Started]] for details.', env)
|
||||
|
||||
expect(result).toContain('<VPLink')
|
||||
// Anchor slug is appended to href, after-text shows the full path hierarchy
|
||||
expect(result).toContain('href="/guide.md#getting-started"')
|
||||
expect(result).toContain('template #after-text')
|
||||
expect(result).toContain('> Introduction > Getting Started')
|
||||
})
|
||||
|
||||
it('should track links in env when rendering inline page embed', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
md.render('See ![[guide]] for details.', env)
|
||||
|
||||
expect(env.links).toContainEqual(
|
||||
expect.objectContaining({
|
||||
raw: 'guide.md',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render inline embed with relative path as external link when not found', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv('docs/page.md')
|
||||
|
||||
const result = md.render('See ![[./guide]] for details.', env)
|
||||
|
||||
// ./guide doesn't resolve to a page in findFirstPage, so renders as external link
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/docs/./guide"')
|
||||
})
|
||||
|
||||
it('should render inline nonexistent page embed as external link', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('See ![[nonexistent]] for details.', env)
|
||||
|
||||
expect(result).toContain('<a')
|
||||
expect(result).toContain('href="/nonexistent"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Inline External Resource with Anchor ====================
|
||||
|
||||
describe('inline external resource with anchor', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should render inline external link with anchor', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('Check ![[https://example.com/page#section]] out.', env)
|
||||
|
||||
expect(result).toContain('<a')
|
||||
// The #section anchor is parsed but since the URL doesn't have a recognized extension,
|
||||
// it's treated as an external link without proper anchor handling
|
||||
expect(result).toContain('href="https://example.com/page"')
|
||||
expect(result).toContain('target="_blank"')
|
||||
})
|
||||
|
||||
it('should render external markdown file with heading anchor', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const env = createMockEnv()
|
||||
|
||||
const result = md.render('Check ![[https://example.com/doc.md#intro]] out.', env)
|
||||
|
||||
expect(result).toContain('<a')
|
||||
// URLs with anchors are not properly handled in current implementation
|
||||
expect(result).toContain('href="https://example.com/doc.md"')
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== Block Embed with Inline-like Content ====================
|
||||
|
||||
describe('block embed with various content', () => {
|
||||
beforeEach(() => {
|
||||
mockGlobSync.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('should handle block embed of image with pipe settings', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[banner.jpg|800x200]]')
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('width: 800px')
|
||||
expect(result).toContain('height: 200px')
|
||||
})
|
||||
|
||||
it('should handle block embed of audio with controls', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[narration.mp3]]')
|
||||
expect(result).toContain('<audio')
|
||||
expect(result).toContain('controls="true"')
|
||||
})
|
||||
|
||||
it('should handle block embed of video with all attributes', () => {
|
||||
const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp())
|
||||
const result = md.render('![[presentation.mp4]]')
|
||||
expect(result).toContain('<ArtPlayer')
|
||||
expect(result).toContain(':fullscreen="true"')
|
||||
expect(result).toContain(':flip="true"')
|
||||
expect(result).toContain(':playback-rate="true"')
|
||||
})
|
||||
})
|
||||
})
|
||||
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",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"description": "The Plugin for VuePress 2 - markdown power",
|
||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||
"license": "MIT",
|
||||
@ -97,6 +97,7 @@
|
||||
"@vuepress/helper": "catalog:vuepress",
|
||||
"@vueuse/core": "catalog:prod",
|
||||
"chokidar": "catalog:prod",
|
||||
"gray-matter": "catalog:prod",
|
||||
"image-size": "catalog:prod",
|
||||
"local-pkg": "catalog:prod",
|
||||
"lru-cache": "catalog:prod",
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { QRCodeToDataURLOptions, QRCodeToStringOptions } from 'qrcode'
|
||||
import type { QRCodeProps } from '../../shared/index.js'
|
||||
import { isLinkWithProtocol } from '@vuepress/helper/client'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { resolveRoute, usePage, withBase } from 'vuepress/client'
|
||||
import { attemptLoadLogo, generateQRCode } from '../composables/qrcode.js'
|
||||
|
||||
const { title, text, mode, align = 'left', reverse = false, svg = false, width, level, version, mask, margin = 2, scale = 4, light, dark } = defineProps<QRCodeProps>()
|
||||
const { title, text, mode, align = 'left', reverse = false, width, level, version, mask, margin = 2, scale = 4, light, dark, logo, logoSize = '0.2' } = defineProps<QRCodeProps>()
|
||||
|
||||
const page = usePage()
|
||||
|
||||
let qr: typeof import('qrcode') | null = null
|
||||
|
||||
const qrcode = ref('')
|
||||
const parsedText = ref('')
|
||||
const imgWidth = ref(300)
|
||||
const isLink = ref(false)
|
||||
const isInternalLink = ref(false)
|
||||
|
||||
const styles = computed(() => {
|
||||
const size = typeof width === 'number' ? width : width ? Number.parseInt(width) : undefined
|
||||
@ -26,6 +26,7 @@ function parseText(): string | void {
|
||||
return ''
|
||||
|
||||
if (text === '.') {
|
||||
isInternalLink.value = true
|
||||
isLink.value = true
|
||||
return location.href.split(/[?#]/)[0]
|
||||
}
|
||||
@ -42,6 +43,7 @@ function parseText(): string | void {
|
||||
if (notFound) {
|
||||
return text
|
||||
}
|
||||
isInternalLink.value = true
|
||||
isLink.value = true
|
||||
return new URL(`${withBase(path)}${rest.join('')}`, location.href).toString()
|
||||
}
|
||||
@ -50,10 +52,8 @@ function parseText(): string | void {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const callback = (_: any, url: string) => qrcode.value = url
|
||||
|
||||
watch(
|
||||
() => [text, svg, level, version, mask, margin, scale, light, dark],
|
||||
() => [text, level, version, mask, margin, scale, light, dark, logo, logoSize],
|
||||
async () => {
|
||||
const text = parseText()
|
||||
parsedText.value = text || ''
|
||||
@ -62,36 +62,39 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
qr ??= (await import(/* webpackChunkName: "qrcode" */ 'qrcode')).default
|
||||
const opts: QRCodeToDataURLOptions & QRCodeToStringOptions = {
|
||||
version,
|
||||
maskPattern: mask,
|
||||
errorCorrectionLevel: (level ? level.toUpperCase() : 'M') as any,
|
||||
width: 300 * Math.round(window.devicePixelRatio || 1),
|
||||
margin,
|
||||
scale,
|
||||
color: { dark, light },
|
||||
}
|
||||
|
||||
if (svg)
|
||||
qr.toString(text, { type: 'svg', ...opts }, callback)
|
||||
else
|
||||
qr.toDataURL(text, { type: 'image/png', ...opts }, callback)
|
||||
imgWidth.value = 300 * Math.round(window.devicePixelRatio || 1)
|
||||
qrcode.value = await generateQRCode(
|
||||
{
|
||||
text,
|
||||
logo: await attemptLoadLogo(text, logo, isInternalLink.value),
|
||||
logoSize,
|
||||
},
|
||||
{
|
||||
version,
|
||||
maskPattern: mask,
|
||||
width: imgWidth.value,
|
||||
margin,
|
||||
scale,
|
||||
color: { dark, light },
|
||||
},
|
||||
)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
qr = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="qrcode" class="vp-qrcode" :class="{ card: mode === 'card', reverse, [align]: true }">
|
||||
<div class="qrcode-content">
|
||||
<div v-if="svg" class="qrcode-svg" :style="styles" :title="parsedText" v-html="qrcode" />
|
||||
<img v-else class="qrcode-img" :src="qrcode" :alt="parsedText" :title="parsedText" :style="styles">
|
||||
<img
|
||||
class="qrcode-img"
|
||||
:src="qrcode"
|
||||
:alt="parsedText"
|
||||
:title="parsedText"
|
||||
:style="styles"
|
||||
:width="imgWidth" :height="imgWidth"
|
||||
>
|
||||
<div v-if="title && mode !== 'card'" class="qrcode-label">
|
||||
{{ title }}
|
||||
</div>
|
||||
@ -166,7 +169,6 @@ onUnmounted(() => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vp-qrcode .qrcode-svg,
|
||||
.vp-qrcode .qrcode-img {
|
||||
width: var(--vp-qrcode-size);
|
||||
max-width: 100%;
|
||||
@ -174,11 +176,6 @@ onUnmounted(() => {
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.vp-qrcode .qrcode-svg :deep(svg) {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.vp-qrcode .qrcode-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@ -110,6 +110,7 @@ function onCopy(type: 'html' | 'md') {
|
||||
}
|
||||
|
||||
.vp-table .table-container table {
|
||||
display: table;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
83
plugins/plugin-md-power/src/client/composables/qrcode.ts
Normal file
83
plugins/plugin-md-power/src/client/composables/qrcode.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { Prettify } from '@pengzhanbo/utils'
|
||||
import type { QRCodeByteSegment, QRCodeErrorCorrectionLevel, QRCodeRenderersOptions, QRCodeToDataURLOptions, QRCodeToStringOptions } from 'qrcode'
|
||||
|
||||
export interface GenerateQRCodeConfig {
|
||||
text: string
|
||||
logo?: string
|
||||
logoSize?: string
|
||||
}
|
||||
|
||||
interface QRCodeInstance {
|
||||
toCanvas: (str: string | QRCodeByteSegment[], options: QRCodeRenderersOptions) => Promise<HTMLCanvasElement>
|
||||
toPNG: (str: string | QRCodeByteSegment[], options: QRCodeToDataURLOptions) => Promise<string>
|
||||
}
|
||||
|
||||
let qr: QRCodeInstance | null = null
|
||||
|
||||
async function initQRCodeInstance() {
|
||||
if (qr)
|
||||
return qr
|
||||
|
||||
const qrcode = (await import(/* webpackChunkName: "qrcode" */ 'qrcode')).default
|
||||
qr = {
|
||||
toCanvas: (text: string | QRCodeByteSegment[], options: QRCodeRenderersOptions) => {
|
||||
return new Promise((resolve, reject) =>
|
||||
qrcode.toCanvas(text, options, (error, canvas) => error ? reject(error) : resolve(canvas)),
|
||||
)
|
||||
},
|
||||
toPNG: (text, options) => qrcode.toDataURL(text, { type: 'image/png', ...options }),
|
||||
}
|
||||
return qr
|
||||
}
|
||||
|
||||
export async function generateQRCode(
|
||||
{ text, logo, logoSize = '0.2' }: GenerateQRCodeConfig,
|
||||
options: Prettify<QRCodeToDataURLOptions & QRCodeToStringOptions & QRCodeRenderersOptions>,
|
||||
): Promise<string> {
|
||||
const { toCanvas, toPNG } = await initQRCodeInstance()
|
||||
const segments: QRCodeByteSegment[] = [{ data: new TextEncoder().encode(text), mode: 'byte' }]
|
||||
|
||||
const qrWidth = options.width!
|
||||
if (logo) {
|
||||
// 有 logo 时,需要设置 errorCorrectionLevel 为 H
|
||||
// 因为 logo 会占用二维码的一部分空间,导致二维码的纠错能力下降
|
||||
// 所以需要增加纠错能力
|
||||
const level = options.errorCorrectionLevel ?? 'H'
|
||||
options.errorCorrectionLevel = (level.length === 1 ? level.toUpperCase() : `${level[0].toUpperCase()}${level[1].toLowerCase()}`) as unknown as QRCodeErrorCorrectionLevel
|
||||
const logoImg = await loadImage(logo)
|
||||
const actualWith = Number.parseFloat(logoSize) * qrWidth
|
||||
const actualHeight = actualWith / logoImg.width * logoImg.height
|
||||
const dx = (qrWidth - actualWith) / 2
|
||||
const dy = (qrWidth - actualHeight) / 2
|
||||
const canvas = await toCanvas(segments, options)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
// 绘制 logo 背景
|
||||
ctx.fillStyle = options.color?.light || '#fff'
|
||||
ctx.roundRect(dx, dy, actualWith, actualHeight, actualWith / 20)
|
||||
ctx.fill()
|
||||
// 绘制 logo 图片
|
||||
ctx.drawImage(logoImg, dx, dy, actualWith, actualHeight)
|
||||
return canvas.toDataURL()
|
||||
}
|
||||
const level = options.errorCorrectionLevel ?? 'M'
|
||||
options.errorCorrectionLevel = (level.length === 1 ? level.toUpperCase() : `${level[0].toUpperCase()}${level[1].toLowerCase()}`) as unknown as QRCodeErrorCorrectionLevel
|
||||
return await toPNG(segments, options)
|
||||
}
|
||||
|
||||
export async function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Failed to load image'))
|
||||
})
|
||||
}
|
||||
|
||||
export async function attemptLoadLogo(text: string, logo: string | undefined, isInternalLink: boolean): Promise<string> {
|
||||
if (logo)
|
||||
return logo
|
||||
if (isInternalLink)
|
||||
return (document.querySelector('link[rel="icon"]') as HTMLLinkElement)?.href
|
||||
return ''
|
||||
}
|
||||
@ -88,6 +88,7 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
|
||||
let isTable = false
|
||||
let colIndex = 0
|
||||
let rowIndex = 0
|
||||
let skipCells = 0
|
||||
for (const token of tableTokens) {
|
||||
if (token.type === 'table_open')
|
||||
isTable = true
|
||||
@ -100,13 +101,39 @@ export function tablePlugin(md: Markdown, options: TableContainerOptions = {}):
|
||||
if (token.type === 'tr_open') {
|
||||
rowIndex++
|
||||
colIndex = 0
|
||||
// 当 th 设置了 colspan 时,需要跳过空单元格
|
||||
skipCells = 0
|
||||
}
|
||||
// cell (rowIndex, colIndex)
|
||||
if (token.type === 'th_open' || token.type === 'td_open') {
|
||||
if (skipCells > 0) {
|
||||
// Skip this empty cell,`th` element only
|
||||
if (token.type === 'th_open')
|
||||
token.hidden = true
|
||||
skipCells--
|
||||
continue
|
||||
}
|
||||
|
||||
colIndex++
|
||||
const classes = cells[rowIndex]?.[colIndex] || rows[rowIndex] || cols[colIndex]
|
||||
if (classes)
|
||||
token.attrJoin('class', classes)
|
||||
|
||||
// Check for colspan attribute
|
||||
const colspanIndex = token.attrIndex('colspan')
|
||||
if (colspanIndex >= 0) {
|
||||
const colspanValue = Number.parseInt(token.attrs![colspanIndex][1])
|
||||
if (!Number.isNaN(colspanValue) && colspanValue > 1) {
|
||||
skipCells = colspanValue - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip the content and close tokens for skipped cells
|
||||
if (skipCells > 0 && (token.type === 'text' || token.type === 'th_close' || token.type === 'td_close')) {
|
||||
if (token.type === 'th_close')
|
||||
// Skip this empty cell,`th` element only
|
||||
token.hidden = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { DemoFile, MarkdownDemoEnv } from '../../../shared/demo.js'
|
||||
|
||||
const SCRIPT_RE = /<script.*?>/
|
||||
const SCRIPT_RE = /<script\b[^>]*>/
|
||||
|
||||
export function insertSetupScript({ export: name, path }: DemoFile, env: MarkdownDemoEnv): void {
|
||||
const imports = `import ${name ? `${name} from ` : ''}'${path}';`
|
||||
|
||||
@ -18,7 +18,7 @@ const installed = {
|
||||
mpegtsjs: isPackageExists('mpegts.js'),
|
||||
}
|
||||
|
||||
const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv']
|
||||
export const SUPPORTED_VIDEO_TYPES = ['mp4', 'mp3', 'webm', 'ogg', 'mpd', 'dash', 'm3u8', 'hls', 'ts', 'flv', 'mkv', 'mov', 'ogv']
|
||||
|
||||
export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
|
||||
createEmbedRuleBlock<ArtPlayerTokenMeta>(md, {
|
||||
@ -51,7 +51,7 @@ export const artPlayerPlugin: PluginWithOptions<never> = (md) => {
|
||||
})
|
||||
}
|
||||
|
||||
function checkSupportType(type?: string) {
|
||||
export function checkSupportType(type?: string) {
|
||||
if (!type)
|
||||
return
|
||||
|
||||
|
||||
@ -13,4 +13,34 @@ export const deLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 Sicherheitswarnung:',
|
||||
warningText: 'Ihre Verbindung ist nicht mit HTTPS verschlüsselt, was ein Risiko für Inhaltslecks darstellt und den Zugriff auf verschlüsselte Inhalte verhindert.',
|
||||
},
|
||||
obsidian: {
|
||||
note: 'Hinweis',
|
||||
quote: 'Zitat',
|
||||
cite: 'Quellenangabe',
|
||||
tip: 'Tipp',
|
||||
hint: 'Hinweis',
|
||||
info: 'Info',
|
||||
todo: 'Aufgabe',
|
||||
success: 'Erfolg',
|
||||
check: 'Prüfung',
|
||||
done: 'Erledigt',
|
||||
warning: 'Warnung',
|
||||
question: 'Frage',
|
||||
help: 'Hilfe',
|
||||
faq: 'FAQ',
|
||||
caution: 'Vorsicht',
|
||||
attention: 'Achtung',
|
||||
failure: 'Fehlschlag',
|
||||
fail: 'Gescheitert',
|
||||
missing: 'Fehlend',
|
||||
danger: 'Gefahr',
|
||||
error: 'Fehler',
|
||||
bug: 'Bug',
|
||||
important: 'Wichtig',
|
||||
example: 'Beispiel',
|
||||
details: 'Details',
|
||||
abstract: 'Zusammenfassung',
|
||||
summary: 'Zusammenfassung',
|
||||
tldr: 'TL;DR',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const enLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 Security Warning:',
|
||||
warningText: 'Your connection is not encrypted with HTTPS, posing a risk of content leakage and preventing access to encrypted content.',
|
||||
},
|
||||
obsidian: {
|
||||
note: 'Note',
|
||||
quote: 'Quote',
|
||||
cite: 'Cite',
|
||||
tip: 'Tip',
|
||||
hint: 'Hint',
|
||||
info: 'Info',
|
||||
todo: 'Todo',
|
||||
success: 'Success',
|
||||
check: 'Check',
|
||||
done: 'Done',
|
||||
warning: 'Warning',
|
||||
question: 'Question',
|
||||
help: 'Help',
|
||||
faq: 'FAQ',
|
||||
caution: 'Caution',
|
||||
attention: 'Attention',
|
||||
failure: 'Failure',
|
||||
fail: 'Fail',
|
||||
missing: 'Missing',
|
||||
danger: 'Danger',
|
||||
error: 'Error',
|
||||
bug: 'Bug',
|
||||
important: 'Important',
|
||||
example: 'Example',
|
||||
details: 'Details',
|
||||
abstract: 'Abstract',
|
||||
summary: 'Summary',
|
||||
tldr: 'TL;DR',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const frLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 Avertissement de sécurité :',
|
||||
warningText: 'Votre connexion n\'est pas chiffrée avec HTTPS, ce qui présente un risque de fuite de contenu et empêche l\'accès au contenu chiffré.',
|
||||
},
|
||||
obsidian: {
|
||||
note: 'Note',
|
||||
quote: 'Citation',
|
||||
cite: 'Référence',
|
||||
tip: 'Astuce',
|
||||
hint: 'Indice',
|
||||
info: 'Info',
|
||||
todo: 'À faire',
|
||||
success: 'Succès',
|
||||
check: 'Vérification',
|
||||
done: 'Terminé',
|
||||
warning: 'Avertissement',
|
||||
question: 'Question',
|
||||
help: 'Aide',
|
||||
faq: 'FAQ',
|
||||
caution: 'Prudence',
|
||||
attention: 'Attention',
|
||||
failure: 'Échec',
|
||||
fail: 'Échoué',
|
||||
missing: 'Manquant',
|
||||
danger: 'Danger',
|
||||
error: 'Erreur',
|
||||
bug: 'Bug',
|
||||
important: 'Important',
|
||||
example: 'Exemple',
|
||||
details: 'Détails',
|
||||
abstract: 'Résumé',
|
||||
summary: 'Sommaire',
|
||||
tldr: 'En bref',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const jaLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 セキュリティ警告:',
|
||||
warningText: '接続がHTTPSで暗号化されていないため、コンテンツの漏洩リスクがあり、暗号化されたコンテンツへのアクセスができません。',
|
||||
},
|
||||
obsidian: {
|
||||
note: 'ノート',
|
||||
quote: '引用',
|
||||
cite: '出典',
|
||||
tip: 'ヒント',
|
||||
hint: '助言',
|
||||
info: '情報',
|
||||
todo: 'やること',
|
||||
success: '成功',
|
||||
check: 'チェック',
|
||||
done: '完了',
|
||||
warning: '警告',
|
||||
question: '質問',
|
||||
help: 'ヘルプ',
|
||||
faq: 'よくある質問',
|
||||
caution: '注意',
|
||||
attention: '注目',
|
||||
failure: '失敗',
|
||||
fail: '未達',
|
||||
missing: '不明',
|
||||
danger: '危険',
|
||||
error: 'エラー',
|
||||
bug: 'バグ',
|
||||
important: '重要',
|
||||
example: '例',
|
||||
details: '詳細',
|
||||
abstract: '要約',
|
||||
summary: 'まとめ',
|
||||
tldr: '短縮版',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const koLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 보안 경고:',
|
||||
warningText: '연결이 HTTPS로 암호화되지 않아 내용 유출 위험이 있으며, 암호화된 콘텐츠에 접근할 수 없습니다.',
|
||||
},
|
||||
obsidian: {
|
||||
note: '참고',
|
||||
quote: '인용',
|
||||
cite: '인용문',
|
||||
tip: '팁',
|
||||
hint: '단서',
|
||||
info: '정보',
|
||||
todo: '할 일',
|
||||
success: '성공',
|
||||
check: '확인',
|
||||
done: '완료',
|
||||
warning: '경고',
|
||||
question: '질문',
|
||||
help: '도움말',
|
||||
faq: '자주 묻는 질문',
|
||||
caution: '주의',
|
||||
attention: '주목',
|
||||
failure: '실패',
|
||||
fail: '미달',
|
||||
missing: '누락',
|
||||
danger: '위험',
|
||||
error: '오류',
|
||||
bug: '버그',
|
||||
important: '중요',
|
||||
example: '예시',
|
||||
details: '세부사항',
|
||||
abstract: '요약',
|
||||
summary: '정리',
|
||||
tldr: '요약',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const ruLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 Предупреждение безопасности:',
|
||||
warningText: 'Ваше соединение не защищено HTTPS, что создает риск утечки данных и блокирует доступ к зашифрованному контенту.',
|
||||
},
|
||||
obsidian: {
|
||||
note: 'Заметка',
|
||||
quote: 'Цитата',
|
||||
cite: 'Ссылка',
|
||||
tip: 'Совет',
|
||||
hint: 'Подсказка',
|
||||
info: 'Информация',
|
||||
todo: 'Сделать',
|
||||
success: 'Успех',
|
||||
check: 'Проверка',
|
||||
done: 'Готово',
|
||||
warning: 'Предупреждение',
|
||||
question: 'Вопрос',
|
||||
help: 'Помощь',
|
||||
faq: 'ЧаВо',
|
||||
caution: 'Осторожно',
|
||||
attention: 'Внимание',
|
||||
failure: 'Неудача',
|
||||
fail: 'Сбой',
|
||||
missing: 'Отсутствует',
|
||||
danger: 'Опасность',
|
||||
error: 'Ошибка',
|
||||
bug: 'Баг',
|
||||
important: 'Важно',
|
||||
example: 'Пример',
|
||||
details: 'Подробности',
|
||||
abstract: 'Аннотация',
|
||||
summary: 'Итоги',
|
||||
tldr: 'Кратко',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const zhTWLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 安全警告:',
|
||||
warningText: '您的連線未使用 HTTPS 加密,可能導致內容洩露風險,無法存取加密內容。',
|
||||
},
|
||||
obsidian: {
|
||||
note: '筆記',
|
||||
quote: '引用',
|
||||
cite: '引文',
|
||||
tip: '提示',
|
||||
hint: '技巧',
|
||||
info: '資訊',
|
||||
todo: '待辦',
|
||||
success: '成功',
|
||||
check: '核對',
|
||||
done: '完成',
|
||||
warning: '警告',
|
||||
question: '疑問',
|
||||
help: '幫助',
|
||||
faq: '常見問題',
|
||||
caution: '注意',
|
||||
attention: '關注',
|
||||
failure: '失敗',
|
||||
fail: '未通過',
|
||||
missing: '缺失',
|
||||
danger: '危險',
|
||||
error: '錯誤',
|
||||
bug: '缺陷',
|
||||
important: '重要',
|
||||
example: '範例',
|
||||
details: '詳情',
|
||||
abstract: '摘要',
|
||||
summary: '總結',
|
||||
tldr: '太長不看',
|
||||
},
|
||||
}
|
||||
|
||||
@ -13,4 +13,34 @@ export const zhLocale: MDPowerLocaleData = {
|
||||
warningTitle: '🚨 安全警告:',
|
||||
warningText: '您的连接未使用HTTPS加密,存在内容泄露风险,无法访问加密内容。',
|
||||
},
|
||||
obsidian: {
|
||||
note: '笔记',
|
||||
quote: '引用',
|
||||
cite: '引文',
|
||||
tip: '提示',
|
||||
hint: '技巧',
|
||||
info: '信息',
|
||||
todo: '待办',
|
||||
success: '成功',
|
||||
check: '核对',
|
||||
done: '完成',
|
||||
warning: '警告',
|
||||
question: '疑问',
|
||||
help: '帮助',
|
||||
faq: '常见问题',
|
||||
caution: '注意',
|
||||
attention: '关注',
|
||||
failure: '失败',
|
||||
fail: '未通过',
|
||||
missing: '缺失',
|
||||
danger: '危险',
|
||||
error: '错误',
|
||||
bug: '缺陷',
|
||||
important: '重要',
|
||||
example: '示例',
|
||||
details: '详情',
|
||||
abstract: '摘要',
|
||||
summary: '总结',
|
||||
tldr: '太长不看',
|
||||
},
|
||||
}
|
||||
|
||||
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: `%%注释%%`
|
||||
431
plugins/plugin-md-power/src/node/obsidian/callouts.ts
Normal file
431
plugins/plugin-md-power/src/node/obsidian/callouts.ts
Normal file
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* obsidian callouts,其表现形式类似于 vuepress alerts
|
||||
*
|
||||
* 差异点:在语法上支持 标题 和 折叠展开
|
||||
*
|
||||
* 1. 此插件将 callouts 所有类型映射到 vuepress alerts 支持的类型中
|
||||
* 2. 忽略 折叠展开功能,在 语法解析上 不处理 折叠展开逻辑
|
||||
* 3. 支持 标题
|
||||
*
|
||||
* @see - https://obsidian.md/zh/help/callouts
|
||||
*/
|
||||
|
||||
import type { PluginWithOptions } from 'markdown-it'
|
||||
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
|
||||
import type { MarkdownEnv } from 'vuepress/markdown'
|
||||
import type { ObsidianCalloutOptions } from '../../shared/index.js'
|
||||
import { capitalize, objectEntries, uniq } from '@pengzhanbo/utils'
|
||||
import { ensureLeadingSlash } from '@vuepress/helper'
|
||||
import { resolveLocalePath } from 'vuepress/shared'
|
||||
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
||||
|
||||
// 将 obsidian callout 映射到 vuepress alert 的类型
|
||||
const calloutsToAlerts: Record<string, string[]> = {
|
||||
note: ['quote', 'cite'],
|
||||
tip: ['hint'],
|
||||
info: ['todo'],
|
||||
success: ['check', 'done'],
|
||||
warning: ['question', 'help', 'faq'],
|
||||
caution: ['attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug'],
|
||||
important: ['example'],
|
||||
details: ['abstract', 'summary', 'tldr'],
|
||||
}
|
||||
|
||||
const callouts = objectEntries(calloutsToAlerts).map(([k, v]) => [k, ...v]).flat()
|
||||
const calloutAlias = objectEntries(calloutsToAlerts)
|
||||
.reduce((acc, [k, v]) => {
|
||||
v.forEach(alias => acc[alias] = k)
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const calloutsDef: RuleBlock = (state, startLine, endLine, silent) => {
|
||||
// if it's indented more than 3 spaces, it should be a code block
|
||||
if (state.sCount[startLine] - state.blkIndent >= 4)
|
||||
return false
|
||||
|
||||
let pos = state.bMarks[startLine] + state.tShift[startLine]
|
||||
let max = state.eMarks[startLine]
|
||||
|
||||
// check the block quote marker
|
||||
if (state.src.charCodeAt(pos) !== 62 /* > */)
|
||||
return false
|
||||
|
||||
let currentPos = pos + 1
|
||||
|
||||
let initial = state.sCount[startLine] + 1
|
||||
let adjustTab = false
|
||||
|
||||
// skip one optional space after '>'
|
||||
if (state.src.charCodeAt(currentPos) === 32 /* space */) {
|
||||
// ' > [!tip] '
|
||||
// ^ -- position start of line here:
|
||||
currentPos++
|
||||
initial++
|
||||
}
|
||||
else if (state.src.charCodeAt(currentPos) === 9 /* tab */) {
|
||||
if ((state.bsCount[startLine] + initial) % 4 === 3) {
|
||||
// ' >\t [!tip] '
|
||||
// ^ -- position start of line here (tab has width===1)
|
||||
currentPos++
|
||||
initial++
|
||||
}
|
||||
else {
|
||||
// ' >\t [!tip] '
|
||||
// ^ -- position start of line here + shift bsCount slightly
|
||||
// to make extra space appear
|
||||
adjustTab = true
|
||||
}
|
||||
}
|
||||
|
||||
let offset = initial
|
||||
|
||||
while (currentPos < max) {
|
||||
const ch = state.src.charCodeAt(currentPos)
|
||||
|
||||
if (ch === 9 /** \t */)
|
||||
offset += 4 - ((offset + state.bsCount[startLine] + (adjustTab ? 1 : 0)) % 4)
|
||||
else if (ch === 32 /** Space */)
|
||||
offset++
|
||||
else break
|
||||
|
||||
currentPos++
|
||||
}
|
||||
|
||||
// skip blockquote
|
||||
if (offset - initial >= 4)
|
||||
return false
|
||||
|
||||
// the minimum length of an alert is 4 characters [!x]
|
||||
if (max - currentPos < 4)
|
||||
return false
|
||||
|
||||
// check opening marker '[!'
|
||||
if (
|
||||
state.src.charCodeAt(currentPos) !== 91
|
||||
|| /* [ */ state.src.charCodeAt(currentPos + 1) !== 33 /* ! */
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
currentPos += 2
|
||||
|
||||
let typeName = ''
|
||||
|
||||
// find closing bracket ']'
|
||||
while (currentPos < max) {
|
||||
const char = state.src.charAt(currentPos)
|
||||
|
||||
if (char === ']')
|
||||
break
|
||||
|
||||
typeName += char
|
||||
currentPos++
|
||||
}
|
||||
|
||||
if (currentPos === max)
|
||||
return false
|
||||
|
||||
const type = typeName.toLowerCase()
|
||||
|
||||
if (!callouts.includes(type))
|
||||
return false
|
||||
|
||||
// skip spaces after ']'
|
||||
currentPos = state.skipSpaces(currentPos + 1)
|
||||
|
||||
// if there are non-space characters after ']', it's not a valid alert
|
||||
// if (currentPos < max)
|
||||
// return false
|
||||
const titleContent = state.src.slice(currentPos, max)
|
||||
|
||||
const oldBMarks: number[] = []
|
||||
const oldBSCount: number[] = []
|
||||
const oldSCount: number[] = []
|
||||
const oldTShift: number[] = []
|
||||
const oldLineMax = state.lineMax
|
||||
const oldParentType = state.parentType
|
||||
const terminatorRules = [
|
||||
state.md.block.ruler.getRules('blockquote'),
|
||||
state.md.block.ruler.getRules('alert'),
|
||||
].flat()
|
||||
|
||||
// @ts-expect-error: We are creating a new type called "alert"
|
||||
state.parentType = 'alert'
|
||||
|
||||
// Search the end of the block
|
||||
//
|
||||
// Block ends with either:
|
||||
// 1. an empty line outside:
|
||||
// ```
|
||||
// > test
|
||||
//
|
||||
// ```
|
||||
// 2. an empty line inside:
|
||||
// ```
|
||||
// >
|
||||
// test
|
||||
// ```
|
||||
// 3. another tag:
|
||||
// ```
|
||||
// > test
|
||||
// - - -
|
||||
// ```
|
||||
let currentLine = startLine
|
||||
let lastLineEmpty = false
|
||||
let hasBodyContent = false
|
||||
|
||||
for (; currentLine < endLine; currentLine++) {
|
||||
// check if it's outdented, i.e. it's inside list item and indented
|
||||
// less than said list item:
|
||||
//
|
||||
// ```
|
||||
// 1. anything
|
||||
// > current blockquote
|
||||
// 2. checking this line
|
||||
// ```
|
||||
const isOutdented = state.sCount[currentLine] < state.blkIndent
|
||||
|
||||
pos = state.bMarks[currentLine] + state.tShift[currentLine]
|
||||
max = state.eMarks[currentLine]
|
||||
|
||||
// Case 1: line is not inside the blockquote, and this line is empty.
|
||||
if (pos >= max)
|
||||
break
|
||||
|
||||
if (state.src.charCodeAt(pos++) === 62 /* > */ && !isOutdented) {
|
||||
// This line is inside the blockquote.
|
||||
|
||||
let spaceAfterMarker = false
|
||||
// set offset past spaces and ">"
|
||||
initial = state.sCount[currentLine] + 1
|
||||
adjustTab = false
|
||||
|
||||
// skip one optional space after '>'
|
||||
if (state.src.charCodeAt(pos) === 32 /* space */) {
|
||||
// ' > test '
|
||||
// ^ -- position start of line here:
|
||||
pos++
|
||||
initial++
|
||||
spaceAfterMarker = true
|
||||
}
|
||||
else if (state.src.charCodeAt(pos) === 9 /* \t */) {
|
||||
spaceAfterMarker = true
|
||||
|
||||
if ((state.bsCount[currentLine] + initial) % 4 === 3) {
|
||||
// ' >\t test '
|
||||
// ^ -- position start of line here (tab has width===1)
|
||||
pos++
|
||||
initial++
|
||||
}
|
||||
else {
|
||||
// ' >\t test '
|
||||
// ^ -- position start of line here + shift bsCount slightly
|
||||
// to make extra space appear
|
||||
adjustTab = true
|
||||
}
|
||||
}
|
||||
|
||||
offset = initial
|
||||
|
||||
if (!silent) {
|
||||
oldBMarks.push(state.bMarks[currentLine])
|
||||
state.bMarks[currentLine] = pos
|
||||
}
|
||||
|
||||
while (pos < max) {
|
||||
const ch = state.src.charCodeAt(pos)
|
||||
|
||||
if (ch === 9 /** \t */)
|
||||
offset += 4 - ((offset + state.bsCount[currentLine] + (adjustTab ? 1 : 0)) % 4)
|
||||
else if (ch === 32 /** Space */)
|
||||
offset++
|
||||
else break
|
||||
|
||||
pos++
|
||||
}
|
||||
|
||||
lastLineEmpty = pos >= max
|
||||
if (currentLine > startLine && !lastLineEmpty)
|
||||
hasBodyContent = true
|
||||
|
||||
if (!silent) {
|
||||
oldBSCount.push(state.bsCount[currentLine])
|
||||
state.bsCount[currentLine] = state.sCount[currentLine] + 1 + (spaceAfterMarker ? 1 : 0)
|
||||
|
||||
oldSCount.push(state.sCount[currentLine])
|
||||
state.sCount[currentLine] = offset - initial
|
||||
|
||||
oldTShift.push(state.tShift[currentLine])
|
||||
state.tShift[currentLine] = pos - state.bMarks[currentLine]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isOutdented)
|
||||
break
|
||||
|
||||
// Case 2: line is not inside the blockquote, and the last line was empty.
|
||||
if (lastLineEmpty)
|
||||
break
|
||||
|
||||
// Case 3: another tag found.
|
||||
let terminate = false
|
||||
|
||||
const terminateRuleLength = terminatorRules.length
|
||||
|
||||
for (let i = 0; i < terminateRuleLength; i++) {
|
||||
const terminatorRule = terminatorRules[i]
|
||||
|
||||
if (terminatorRule(state, currentLine, endLine, true)) {
|
||||
terminate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (terminate) {
|
||||
// Quirk to enforce "hard termination mode" for paragraphs;
|
||||
// normally if you call `tokenize(state, startLine, nextLine)`,
|
||||
// paragraphs will look below nextLine for paragraph continuation,
|
||||
// but if blockquote is terminated by another tag, they shouldn't
|
||||
state.lineMax = currentLine
|
||||
|
||||
if (state.blkIndent !== 0 && !silent) {
|
||||
// state.blkIndent was non-zero, we now set it to zero,
|
||||
// so we need to re-calculate all offsets to appear as
|
||||
// if indent wasn't changed
|
||||
oldBMarks.push(state.bMarks[currentLine])
|
||||
oldBSCount.push(state.bsCount[currentLine])
|
||||
oldSCount.push(state.sCount[currentLine])
|
||||
oldTShift.push(state.tShift[currentLine])
|
||||
|
||||
state.sCount[currentLine] -= state.blkIndent
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
hasBodyContent = true
|
||||
|
||||
if (!silent) {
|
||||
oldBMarks.push(state.bMarks[currentLine])
|
||||
oldBSCount.push(state.bsCount[currentLine])
|
||||
oldSCount.push(state.sCount[currentLine])
|
||||
oldTShift.push(state.tShift[currentLine])
|
||||
|
||||
// A negative indentation means that this is a paragraph continuation
|
||||
// we only set it if it's not the first line of the body
|
||||
if (currentLine > startLine + 1)
|
||||
state.sCount[currentLine] = -1
|
||||
}
|
||||
}
|
||||
|
||||
const restoreState = (): void => {
|
||||
state.lineMax = oldLineMax
|
||||
|
||||
// Restore original tShift; this might not be necessary since the parser
|
||||
// has already been here, but just to make sure we can do that.
|
||||
for (let i = 0; i < oldTShift.length; i++) {
|
||||
state.bMarks[i + startLine] = oldBMarks[i]
|
||||
state.tShift[i + startLine] = oldTShift[i]
|
||||
state.sCount[i + startLine] = oldSCount[i]
|
||||
state.bsCount[i + startLine] = oldBSCount[i]
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find any alert body, so we don't have a valid alert
|
||||
if (startLine + 1 >= currentLine || !hasBodyContent) {
|
||||
state.parentType = oldParentType
|
||||
|
||||
// If we are in silent mode, we don't need to restore the state
|
||||
if (!silent)
|
||||
restoreState()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// from now we know that it's going to be a valid alert,
|
||||
// so no point trying to find the end of it in silent mode
|
||||
if (silent)
|
||||
return true
|
||||
|
||||
const oldIndent = state.blkIndent
|
||||
|
||||
state.blkIndent = 0
|
||||
|
||||
const titleLines: [number, number] = [startLine, startLine + 1]
|
||||
const contentLines: [number, number] = [startLine + 1, 0]
|
||||
|
||||
const openToken = state.push('alert_open', 'div', 1)
|
||||
|
||||
openToken.markup = type
|
||||
openToken.attrJoin('class', `markdown-alert markdown-alert-${type}`)
|
||||
openToken.map = contentLines
|
||||
|
||||
const titleToken = state.push('alert_title', '', 0)
|
||||
|
||||
titleToken.attrJoin('class', `markdown-alert-title`)
|
||||
titleToken.markup = type
|
||||
titleToken.content = typeName
|
||||
titleToken.map = titleLines
|
||||
titleToken.meta = { type, typeName, content: titleContent }
|
||||
|
||||
state.md.block.tokenize(state, startLine + 1, currentLine)
|
||||
|
||||
const closeToken = state.push('alert_close', 'div', -1)
|
||||
|
||||
closeToken.markup = type
|
||||
contentLines[1] = state.line
|
||||
|
||||
state.blkIndent = oldIndent
|
||||
state.parentType = oldParentType
|
||||
restoreState()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const calloutPlugin: PluginWithOptions<ObsidianCalloutOptions> = (
|
||||
md,
|
||||
{
|
||||
openRender,
|
||||
closeRender,
|
||||
titleRender,
|
||||
locales = {},
|
||||
} = {},
|
||||
) => {
|
||||
md.block.ruler.before(
|
||||
'blockquote',
|
||||
'alert',
|
||||
calloutsDef,
|
||||
{
|
||||
alt: ['paragraph', 'reference', 'blockquote', 'list'],
|
||||
},
|
||||
)
|
||||
|
||||
md.renderer.rules.alert_open = openRender
|
||||
?? ((tokens, index) => {
|
||||
const type = tokens[index].markup
|
||||
const actualType = calloutAlias[type] || type
|
||||
const tag = actualType === 'details' ? 'details' : 'div'
|
||||
return `<${tag} class="hint-container ${uniq([actualType, type]).join(' ')}">\n`
|
||||
})
|
||||
|
||||
md.renderer.rules.alert_close = closeRender ?? ((tokens, index) => {
|
||||
const type = tokens[index].markup
|
||||
const actualType = calloutAlias[type] || type
|
||||
return `</${actualType === 'details' ? 'details' : 'div'}>\n`
|
||||
})
|
||||
|
||||
md.renderer.rules.alert_title = titleRender
|
||||
?? ((tokens, index, _, env: MarkdownEnv): string => {
|
||||
const { type, content } = tokens[index].meta
|
||||
const actualType = calloutAlias[type] || type
|
||||
const tag = actualType === 'details' ? 'summary' : 'p'
|
||||
const title = content.replace(/^\s*[+-]?/, '').trim()
|
||||
const rendered = title ? md.renderInline(title, cleanMarkdownEnv(env)) : ''
|
||||
const relativePath = ensureLeadingSlash(env.filePathRelative ?? '')
|
||||
const locale = resolveLocalePath(locales, relativePath)
|
||||
return `<${tag}${tag === 'summary' ? '' : ' class="hint-container-title"'}>${
|
||||
rendered || locales[locale]?.[type] || capitalize(type)
|
||||
}</${tag}>\n`
|
||||
})
|
||||
}
|
||||
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 = () => ''
|
||||
}
|
||||
392
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
392
plugins/plugin-md-power/src/node/obsidian/embedLink.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Embed Link 是属于 obsidian 官方扩展的 markdown 语法
|
||||
*
|
||||
* - ![[文件名]] ![[文件名#标题]] ![[文件名#标题#标题]]
|
||||
* - ![[资源链接]]:
|
||||
* - ![[图片]] ![[图片|width]] ![[图片|widthxheight]]
|
||||
* - ![[pdf]] ![[pdf#page=1#height=300]]
|
||||
* - ![[音频]]
|
||||
* - ![[视频]]
|
||||
*
|
||||
* @see - https://obsidian.md/zh/help/embeds
|
||||
* @see - https://obsidian.md/zh/help/file-formats
|
||||
*
|
||||
* 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。
|
||||
*/
|
||||
|
||||
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
|
||||
import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs'
|
||||
import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs'
|
||||
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
|
||||
import type { App } from 'vuepress'
|
||||
import type { Markdown, MarkdownEnv } from 'vuepress/markdown'
|
||||
import { attempt } from '@pengzhanbo/utils'
|
||||
import grayMatter from 'gray-matter'
|
||||
import Token from 'markdown-it/lib/token.mjs'
|
||||
import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared'
|
||||
import { fs, hash, path } from 'vuepress/utils'
|
||||
import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js'
|
||||
import { resolvePaths } from '../enhance/links.js'
|
||||
import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js'
|
||||
import { parseRect } from '../utils/parseRect.js'
|
||||
import { slugify } from '../utils/slugify.js'
|
||||
import { findFirstPage } from './findFirstPage.js'
|
||||
|
||||
interface EmbedLinkMeta {
|
||||
filename: string
|
||||
hashes: string[]
|
||||
settings: string
|
||||
isInline: boolean
|
||||
}
|
||||
|
||||
const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm']
|
||||
const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc']
|
||||
const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`)
|
||||
|
||||
const blockEmbedLinkDef: RuleBlock = (state, startLine, endLine, silent) => {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine]
|
||||
const max = state.eMarks[startLine]
|
||||
|
||||
// - ![[]]
|
||||
if (max - start < 6)
|
||||
return false
|
||||
|
||||
// 是否以 `![[` 开头
|
||||
if (
|
||||
state.src.charCodeAt(start) !== 0x21 // \!
|
||||
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|
||||
|| state.src.charCodeAt(start + 2) !== 0x5B // [
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const line = state.src.slice(start, max).trim()
|
||||
// 是否以 `]]` 结尾
|
||||
if (
|
||||
line.charCodeAt(line.length - 1) !== 0x5D // ]
|
||||
|| line.charCodeAt(line.length - 2) !== 0x5D // ]
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return true
|
||||
|
||||
// ![[xxxx]]
|
||||
// ^^^^ <- content
|
||||
const content = line.slice(3, -2).trim()
|
||||
genEmbedAsset(state, content)
|
||||
|
||||
state.line = startLine + 1
|
||||
return true
|
||||
}
|
||||
|
||||
const inlineEmbedLinkDef: RuleInline = (state, silent) => {
|
||||
let found = false
|
||||
const max = state.posMax
|
||||
const start = state.pos
|
||||
|
||||
if (
|
||||
state.src.charCodeAt(start) !== 0x21 // \!
|
||||
|| state.src.charCodeAt(start + 1) !== 0x5B // [
|
||||
|| state.src.charCodeAt(start + 2) !== 0x5B // [
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
/* istanbul ignore if -- @preserve */
|
||||
if (silent)
|
||||
return false
|
||||
|
||||
// - ![[]]
|
||||
if (max - start < 6)
|
||||
return false
|
||||
|
||||
state.pos = start + 2
|
||||
|
||||
// 查找 ]]
|
||||
while (state.pos < max) {
|
||||
if (state.src.charCodeAt(state.pos) === 0x5D
|
||||
&& state.src.charCodeAt(state.pos + 1) === 0x5D) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
state.md.inline.skipToken(state)
|
||||
}
|
||||
|
||||
if (!found || start + 2 === state.pos) {
|
||||
state.pos = start
|
||||
return false
|
||||
}
|
||||
// [[xxxx]]
|
||||
// ^^^^ <- content
|
||||
const content = state.src.slice(start + 3, state.pos).trim()
|
||||
// found!
|
||||
state.posMax = state.pos
|
||||
state.pos = start + 3
|
||||
|
||||
genEmbedAsset(state, content, true)
|
||||
|
||||
state.pos = state.posMax + 2
|
||||
state.posMax = max
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function embedLinkPlugin(md: Markdown, app: App): void {
|
||||
md.block.ruler.before(
|
||||
'import_code',
|
||||
'obsidian_block_embed_link',
|
||||
blockEmbedLinkDef,
|
||||
{ alt: ['paragraph', 'reference', 'blockquote', 'list'] },
|
||||
)
|
||||
md.inline.ruler.before('emphasis', 'obsidian_inline_embed_link', inlineEmbedLinkDef)
|
||||
|
||||
md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => {
|
||||
const token = tokens[idx]
|
||||
const { filename, hashes, settings, isInline } = token.meta as EmbedLinkMeta
|
||||
const pagePath = findFirstPage(filename, env.filePathRelative ?? '')
|
||||
// 行内规则,解析为链接
|
||||
if (isInline && pagePath) {
|
||||
const anchor = hashes.at(-1)
|
||||
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||
const { absolutePath, relativePath } = resolvePaths(
|
||||
pagePath,
|
||||
env.base || '/',
|
||||
env.filePathRelative ?? null,
|
||||
)
|
||||
;(env.links ??= []).push({
|
||||
raw: pagePath,
|
||||
absolute: absolutePath,
|
||||
relative: relativePath,
|
||||
})
|
||||
return `<VPLink href="${ensureLeadingSlash(pagePath)}${slug}">${md.utils.escapeHtml(settings) || (hashes.length ? `<template #after-text>${md.utils.escapeHtml(` > ${hashes.join(' > ')}`)}</template>` : '')}</VPLink>`
|
||||
}
|
||||
|
||||
// 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面
|
||||
if (pagePath) {
|
||||
const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8'))
|
||||
if (error) {
|
||||
console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`)
|
||||
return ''
|
||||
}
|
||||
const { content: rawContent } = grayMatter(markdown)
|
||||
if (!rawContent) {
|
||||
console.warn(`[embedLinkPlugin] file ${pagePath} is empty`)
|
||||
return ''
|
||||
}
|
||||
const content = extractContentByHeadings(rawContent, hashes)
|
||||
pagePath && (env.importedFiles ??= []).push(pagePath)
|
||||
return md.render(content, cleanMarkdownEnv(env))
|
||||
}
|
||||
|
||||
// 其他资源,解析为链接
|
||||
const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename)
|
||||
const anchor = hashes.at(-1)
|
||||
const slug = anchor ? `#${slugify(anchor)}` : ''
|
||||
const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : ''))
|
||||
return `<a href="${url}${slug}" target="_blank" rel="noopener noreferrer">${
|
||||
md.utils.escapeHtml(text)
|
||||
}</a>`
|
||||
}
|
||||
}
|
||||
|
||||
function genEmbedAsset(state: StateBlock | StateInline, content: string, isInline = false): void {
|
||||
const [file, settings] = content.split('|').map(x => x.trim())
|
||||
const [filename, ...hashes] = file.trim().split('#').map(x => x.trim())
|
||||
const extname = path.extname(filename).toLowerCase()
|
||||
|
||||
// 渲染为 图片
|
||||
if (EXTENSION_IMAGES.includes(extname)) {
|
||||
const token = state.push('image', 'img', 1)
|
||||
token.content = filename
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('alt', filename)
|
||||
if (settings) {
|
||||
const [width, height] = settings.split('x').map(x => x.trim())
|
||||
const styles: string[] = []
|
||||
if (width)
|
||||
styles.push(`width: ${parseRect(width)}`)
|
||||
if (height)
|
||||
styles.push(`height: ${parseRect(height)}`)
|
||||
token.attrSet('style', styles.join(';'))
|
||||
}
|
||||
const text = new Token('text', '', 0)
|
||||
text.content = filename
|
||||
token.children = [text]
|
||||
}
|
||||
// 渲染为音频
|
||||
else if (EXTENSION_AUDIOS.includes(extname)) {
|
||||
const token = state.push('audio_open', 'audio', 1)
|
||||
token.content = filename
|
||||
token.attrSet('controls', 'true')
|
||||
token.attrSet('preload', 'metadata')
|
||||
token.attrSet('aria-label', filename)
|
||||
const sourceToken = state.push('source_open', 'source', 1)
|
||||
sourceToken.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
state.push('audio_close', 'audio', -1)
|
||||
}
|
||||
// 渲染为视频,使用 ArtPlayer
|
||||
else if (EXTENSION_VIDEOS.includes(extname)) {
|
||||
const token = state.push('video_artPlayer_open', 'ArtPlayer', 1)
|
||||
const type = extname.slice(1)
|
||||
checkSupportType(type)
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('type', type)
|
||||
token.attrSet('width', '100%')
|
||||
token.attrSet(':fullscreen', 'true')
|
||||
token.attrSet(':flip', 'true')
|
||||
token.attrSet(':playback-rate', 'true')
|
||||
token.attrSet(':aspect-ratio', 'true')
|
||||
token.attrSet(':setting', 'true')
|
||||
token.attrSet(':pip', 'true')
|
||||
token.attrSet(':volume', '0.75')
|
||||
token.content = filename
|
||||
state.push('video_artPlayer_close', 'ArtPlayer', -1)
|
||||
}
|
||||
// 渲染为 pdf
|
||||
else if (extname === '.pdf') {
|
||||
const token = state.push('pdf_open', 'PDFViewer', 1)
|
||||
token.attrSet('src', resolveFilenameToAssetPath(filename))
|
||||
token.attrSet('width', '100%')
|
||||
for (const hash of hashes) {
|
||||
const [key, value] = hash.split('=').map(x => x.trim())
|
||||
token.attrSet(key, value)
|
||||
}
|
||||
token.content = filename
|
||||
state.push('pdf_close', 'PDFViewer', -1)
|
||||
}
|
||||
// 非受支持的外部资源,渲染为链接
|
||||
else if (isLinkHttp(filename) || (extname && extname !== '.md')) {
|
||||
const token = state.push('link_open', 'a', 1)
|
||||
token.attrSet('href', filename)
|
||||
token.attrSet('target', '_blank')
|
||||
token.attrSet('rel', 'noopener noreferrer')
|
||||
token.content = filename
|
||||
const content = state.push('text', '', 0)
|
||||
content.content = filename
|
||||
state.push('link_close', 'a', -1)
|
||||
}
|
||||
// 剩余情况,如内部的 markdown 文件
|
||||
// 在 obsidian_embed_link renderer rule 中处理
|
||||
else {
|
||||
const token = state.push('obsidian_embed_link', '', 0)
|
||||
token.markup = '![[]]'
|
||||
token.meta = {
|
||||
filename: filename.trim(),
|
||||
hashes: hashes.map(hash => hash.trim()),
|
||||
settings: settings?.trim(),
|
||||
isInline,
|
||||
} as EmbedLinkMeta
|
||||
token.content = content
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFilenameToAssetPath(filename: string): string {
|
||||
if (isLinkHttp(filename) || filename[0] === '.' || filename[0] === '/') {
|
||||
return filename
|
||||
}
|
||||
return `/${filename}`
|
||||
}
|
||||
|
||||
interface ParsedHeading {
|
||||
lineIndex: number
|
||||
level: number
|
||||
text: string
|
||||
}
|
||||
|
||||
// 支持: ## 标题 {#id .class key=value} 或 ## 标题 {#id}
|
||||
const HEADING_HASH_REG = /^#+/
|
||||
const HEADING_ATTRS_REG = /(?:\{[^}]*\})?$/
|
||||
|
||||
function extractContentByHeadings(content: string, headings: string[]): string {
|
||||
if (!headings.length)
|
||||
return content
|
||||
|
||||
const containers: Record<string, string> = {}
|
||||
|
||||
content = content.replaceAll(/(?<mark>:{3,})[\s\S]*?\k<mark>/g, (matched) => {
|
||||
const key = hash(matched)
|
||||
containers[key] = matched
|
||||
return `<!--container:${key}-->`
|
||||
})
|
||||
const lines = content.split(/\r?\n/)
|
||||
|
||||
const allHeadings: ParsedHeading[] = []
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let text = lines[i].trimEnd()
|
||||
let level = 0
|
||||
text = text.replace(HEADING_HASH_REG, (matched) => {
|
||||
level = matched.length
|
||||
return ''
|
||||
})
|
||||
if (level) {
|
||||
text = text.replace(HEADING_ATTRS_REG, '').trim()
|
||||
allHeadings.push({ lineIndex: i, level, text })
|
||||
}
|
||||
}
|
||||
|
||||
// 查找匹配的标题序列(逻辑同上)
|
||||
let targetHeadingIndex = -1
|
||||
let currentLevel = 0
|
||||
let headingPointer = 0
|
||||
|
||||
for (let i = 0; i < allHeadings.length; i++) {
|
||||
const heading = allHeadings[i]
|
||||
|
||||
if (headingPointer === 0) {
|
||||
if (heading.text === headings[0]) {
|
||||
headingPointer++
|
||||
currentLevel = heading.level
|
||||
if (headingPointer === headings.length) {
|
||||
targetHeadingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (heading.level > currentLevel && heading.text === headings[headingPointer]) {
|
||||
headingPointer++
|
||||
currentLevel = heading.level
|
||||
if (headingPointer === headings.length) {
|
||||
targetHeadingIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
else if (heading.level <= currentLevel) {
|
||||
if (heading.text === headings[0]) {
|
||||
headingPointer = 1
|
||||
currentLevel = heading.level
|
||||
}
|
||||
else {
|
||||
headingPointer = 0
|
||||
currentLevel = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetHeadingIndex === -1) {
|
||||
console.warn(`No heading found for ${headings.join(' > ')}`)
|
||||
return ''
|
||||
}
|
||||
|
||||
const targetHeading = allHeadings[targetHeadingIndex]
|
||||
const startLine = targetHeading.lineIndex + 1
|
||||
const targetLevel = targetHeading.level
|
||||
|
||||
let endLine = lines.length
|
||||
for (let i = targetHeadingIndex + 1; i < allHeadings.length; i++) {
|
||||
if (allHeadings[i].level <= targetLevel) {
|
||||
endLine = allHeadings[i].lineIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const result = lines.slice(startLine, endLine).join('\n').trim()
|
||||
|
||||
return result.replaceAll(/<!--container:(.*?)-->/g, (_, key) => containers[key] ?? '')
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
45
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
45
plugins/plugin-md-power/src/node/obsidian/index.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { App } from 'vuepress'
|
||||
import type { Markdown } from 'vuepress/markdown'
|
||||
import type { MarkdownPowerPluginOptions, MDPowerLocaleData, ObsidianLocaleData } from '../../shared/index.js'
|
||||
import { deepAssign, type ExactLocaleConfig } from '@vuepress/helper'
|
||||
import { isPlainObject } from 'vuepress/shared'
|
||||
import { findLocales } from '../utils/findLocales.js'
|
||||
import { calloutPlugin } from './callouts.js'
|
||||
import { commentPlugin } from './comment.js'
|
||||
import { embedLinkPlugin } from './embedLink.js'
|
||||
import { initPagePaths } from './findFirstPage.js'
|
||||
import { wikiLinkPlugin } from './wikiLink.js'
|
||||
|
||||
export * from './findFirstPage.js'
|
||||
|
||||
export function obsidianPlugin(
|
||||
app: App,
|
||||
md: Markdown,
|
||||
options: MarkdownPowerPluginOptions,
|
||||
locales: ExactLocaleConfig<MDPowerLocaleData>,
|
||||
) {
|
||||
if (options.obsidian === false)
|
||||
return
|
||||
|
||||
const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {}
|
||||
const obsidianLocales = findLocales(locales, 'obsidian')
|
||||
|
||||
initPagePaths(app)
|
||||
|
||||
if (obsidian.wikiLink !== false)
|
||||
wikiLinkPlugin(md)
|
||||
|
||||
if (obsidian.embedLink !== false)
|
||||
embedLinkPlugin(md, app)
|
||||
|
||||
if (obsidian.comment !== false)
|
||||
commentPlugin(md)
|
||||
|
||||
if (obsidian.callout !== false) {
|
||||
const { locales = {}, ...options } = isPlainObject(obsidian.callout) ? obsidian.callout : {}
|
||||
calloutPlugin(md, {
|
||||
...options,
|
||||
locales: deepAssign<Record<string, ObsidianLocaleData>>({}, obsidianLocales, locales),
|
||||
})
|
||||
}
|
||||
}
|
||||
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 { inlineSyntaxPlugin } from './inline/index.js'
|
||||
import { LOCALE_OPTIONS } from './locales/index.js'
|
||||
import { obsidianPlugin, updatePagePaths } from './obsidian/index.js'
|
||||
import { prepareConfigFile } from './prepareConfigFile.js'
|
||||
import { provideData } from './provideData.js'
|
||||
|
||||
@ -111,6 +112,8 @@ export function markdownPowerPlugin(
|
||||
|
||||
await containerPlugin(app, md, options, locales)
|
||||
await imageSizePlugin(app, md, options.imageSize)
|
||||
|
||||
obsidianPlugin(app, md, options, locales)
|
||||
},
|
||||
|
||||
onPrepared: async () => {
|
||||
@ -131,6 +134,13 @@ export function markdownPowerPlugin(
|
||||
if (options.codeTree)
|
||||
extendsPageWithCodeTree(page)
|
||||
},
|
||||
|
||||
onPageUpdated(_app, type, newPage, oldPage) {
|
||||
if (type === 'create')
|
||||
updatePagePaths(newPage?.filePathRelative ?? '', 'create')
|
||||
if (type === 'delete')
|
||||
updatePagePaths(oldPage?.filePathRelative ?? newPage?.filePathRelative ?? '', 'delete')
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@ -9,6 +9,7 @@ export * from './icon.js'
|
||||
export * from './jsfiddle.js'
|
||||
export * from './locale.js'
|
||||
export * from './npmTo.js'
|
||||
export * from './obsidian.js'
|
||||
export * from './pdf.js'
|
||||
export * from './plot.js'
|
||||
export * from './plugin.js'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { LocaleData } from 'vuepress'
|
||||
import type { EncryptSnippetLocale } from './encrypt'
|
||||
import type { ObsidianLocaleData } from './obsidian'
|
||||
|
||||
/**
|
||||
* Markdown Power Plugin Locale Data
|
||||
@ -19,6 +20,12 @@ export interface MDPowerLocaleData extends LocaleData {
|
||||
* 加密片段本地化数据
|
||||
*/
|
||||
encrypt?: EncryptSnippetLocale
|
||||
/**
|
||||
* Obsidian locale data
|
||||
*
|
||||
* Obsidian 本地化数据
|
||||
*/
|
||||
obsidian?: ObsidianLocaleData
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
46
plugins/plugin-md-power/src/shared/obsidian.ts
Normal file
46
plugins/plugin-md-power/src/shared/obsidian.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
|
||||
|
||||
export interface ObsidianOptions {
|
||||
wikiLink?: boolean
|
||||
embedLink?: boolean
|
||||
comment?: boolean
|
||||
callout?: boolean | ObsidianCalloutOptions
|
||||
}
|
||||
|
||||
export interface ObsidianCalloutOptions {
|
||||
locales?: Record<string, ObsidianLocaleData>
|
||||
openRender?: RenderRule
|
||||
closeRender?: RenderRule
|
||||
titleRender?: RenderRule
|
||||
}
|
||||
|
||||
export interface ObsidianLocaleData {
|
||||
note?: string
|
||||
quote?: string
|
||||
cite?: string
|
||||
tip?: string
|
||||
hint?: string
|
||||
info?: string
|
||||
todo?: string
|
||||
success?: string
|
||||
check?: string
|
||||
done?: string
|
||||
warning?: string
|
||||
question?: string
|
||||
help?: string
|
||||
faq?: string
|
||||
caution?: string
|
||||
attention?: string
|
||||
failure?: string
|
||||
fail?: string
|
||||
missing?: string
|
||||
danger?: string
|
||||
error?: string
|
||||
bug?: string
|
||||
important?: string
|
||||
example?: string
|
||||
details?: string
|
||||
abstract?: string
|
||||
summary?: string
|
||||
tldr?: string
|
||||
}
|
||||
@ -18,7 +18,7 @@ export interface PDFTokenMeta extends SizeOptions {
|
||||
*
|
||||
* 要显示的页码
|
||||
*/
|
||||
page?: number
|
||||
page?: number | string
|
||||
/**
|
||||
* Whether to hide toolbar
|
||||
*
|
||||
|
||||
@ -9,6 +9,7 @@ import type { IconOptions } from './icon.js'
|
||||
import type { MDPowerLocaleData } from './locale.js'
|
||||
import type { MarkOptions } from './mark.js'
|
||||
import type { NpmToOptions } from './npmTo.js'
|
||||
import type { ObsidianOptions } from './obsidian.js'
|
||||
import type { PDFOptions } from './pdf.js'
|
||||
import type { PlotOptions } from './plot.js'
|
||||
import type { ReplOptions } from './repl.js'
|
||||
@ -406,5 +407,12 @@ export interface MarkdownPowerPluginOptions {
|
||||
*/
|
||||
imageSize?: boolean | 'local' | 'all'
|
||||
|
||||
/**
|
||||
* 是否启用 obsidian 官方 markdown 扩展语法的兼容性支持
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
obsidian?: boolean | ObsidianOptions
|
||||
|
||||
locales?: LocaleConfig<MDPowerLocaleData>
|
||||
}
|
||||
|
||||
@ -33,6 +33,26 @@ export interface QRCodeProps {
|
||||
* 二维码内容
|
||||
*/
|
||||
text?: string
|
||||
|
||||
/**
|
||||
* Logo image
|
||||
*
|
||||
* 二维码中的 logo 图片
|
||||
*/
|
||||
logo?: string
|
||||
|
||||
/**
|
||||
* Logo image size
|
||||
*
|
||||
* Unit: percentage of QR code width
|
||||
*
|
||||
* 二维码中的 logo 图片大小
|
||||
*
|
||||
* 单位:二维码宽度的百分比
|
||||
*
|
||||
* @default 0.2
|
||||
*/
|
||||
logoSize?: string
|
||||
/**
|
||||
* QR code width
|
||||
*
|
||||
@ -44,7 +64,6 @@ export interface QRCodeProps {
|
||||
* Display mode
|
||||
* - img: Display QR code as image
|
||||
* - card: Display as card with left-right layout, QR code on left, title + content on right
|
||||
* @default 'img'
|
||||
*
|
||||
* 显示模式
|
||||
* - img: 以图片的形式显示二维码
|
||||
@ -62,27 +81,15 @@ export interface QRCodeProps {
|
||||
|
||||
/**
|
||||
* QR code alignment
|
||||
* @default 'left'
|
||||
*
|
||||
* 二维码的对齐方式
|
||||
* @default 'left'
|
||||
*/
|
||||
align?: 'left' | 'center' | 'right'
|
||||
|
||||
/**
|
||||
* Whether to render as SVG format
|
||||
* Default output is PNG format dataURL
|
||||
* @default false
|
||||
*
|
||||
* 是否渲染为 SVG 格式的二维码
|
||||
* 默认输出为 PNG 格式的 dataURL
|
||||
* @default false
|
||||
*/
|
||||
svg?: boolean
|
||||
/**
|
||||
* Error correction level.
|
||||
* Possible values: Low, Medium, Quartile, High, corresponding to L, M, Q, H.
|
||||
* @default 'M'
|
||||
*
|
||||
* 纠错等级。
|
||||
* 可能的取值为低、中、四分位、高,分别对应 L、M、Q、H。
|
||||
@ -109,7 +116,6 @@ export interface QRCodeProps {
|
||||
mask?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
/**
|
||||
* Define how wide the quiet zone should be.
|
||||
* @default 4
|
||||
*
|
||||
* 定义静区应有多宽。
|
||||
* @default 4
|
||||
@ -125,7 +131,6 @@ export interface QRCodeProps {
|
||||
/**
|
||||
* Color of dark modules. Value must be in hexadecimal format (RGBA).
|
||||
* Note: Dark should always be darker than light module color.
|
||||
* @default '#000000ff'
|
||||
*
|
||||
* 暗色模块的颜色。值必须为十六进制格式(RGBA)。
|
||||
* 注意:暗色应始终比浅色模块的颜色更深。
|
||||
@ -136,7 +141,6 @@ export interface QRCodeProps {
|
||||
/**
|
||||
* Color of light modules. Value must be in hexadecimal format (RGBA).
|
||||
* Note: Light should always be lighter than dark module color.
|
||||
* @default '#ffffffff'
|
||||
*
|
||||
* 亮色模块的颜色。值必须为十六进制格式(RGBA)。
|
||||
* 注意:亮色应始终比暗色模块的颜色更浅。
|
||||
|
||||
@ -2,7 +2,7 @@ import { defineConfig, type UserConfig } from 'tsdown'
|
||||
import { argv } from '../../scripts/tsdown-args'
|
||||
|
||||
const config = [
|
||||
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts', 'mark.ts', 'decrypt.ts'] },
|
||||
{ dir: 'composables', files: ['codeRepl.ts', 'pdf.ts', 'rustRepl.ts', 'size.ts', 'audio.ts', 'demo.ts', 'mark.ts', 'decrypt.ts', 'qrcode.ts'] },
|
||||
{ dir: 'utils', files: ['http.ts', 'link.ts', 'sleep.ts'] },
|
||||
{ dir: '', files: ['index.ts', 'options.ts'] },
|
||||
]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vuepress-plume/plugin-search",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"description": "The Plugin for VuePress 2 - local search",
|
||||
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
@ -165,6 +165,14 @@ export async function prepareSearchIndex({
|
||||
}
|
||||
}
|
||||
|
||||
let updateQueue = Promise.resolve()
|
||||
|
||||
async function queueUpdateIndexFile({ page, isSearchable, searchOptions }: UpdateSearchIndexOptions) {
|
||||
updateQueue = updateQueue
|
||||
.then(() => indexFile(page, searchOptions, isSearchable))
|
||||
return updateQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search index update when a page is modified.
|
||||
*
|
||||
@ -187,7 +195,8 @@ export async function onSearchIndexUpdated(
|
||||
if (isSearchable && !isSearchable(page))
|
||||
return
|
||||
|
||||
await indexFile(page, searchOptions, isSearchable)
|
||||
// FIXME: onPageUpdated 存在竞态问题,当前使用异步队列避免
|
||||
await queueUpdateIndexFile({ page, isSearchable, searchOptions })
|
||||
await writeTemp(app)
|
||||
}
|
||||
|
||||
@ -297,8 +306,11 @@ async function indexFile(page: Page, options: SearchIndexOptions['searchOptions'
|
||||
${page.contentRendered}`
|
||||
const sections = splitPageIntoSections(html)
|
||||
|
||||
if (cache && cache.length)
|
||||
index.removeAll(cache)
|
||||
try {
|
||||
if (cache && cache.length)
|
||||
index.removeAll(cache)
|
||||
}
|
||||
catch {}
|
||||
|
||||
// add sections to the locale index
|
||||
for await (const section of sections) {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { SearchPluginOptions } from '../shared/index.js'
|
||||
import { addViteOptimizeDepsInclude, getFullLocaleConfig } from '@vuepress/helper'
|
||||
import { getDirname, path } from 'vuepress/utils'
|
||||
import { SEARCH_LOCALES } from './locales/index.js'
|
||||
import { onSearchIndexRemoved, onSearchIndexUpdated, prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
|
||||
import { /* onSearchIndexRemoved, onSearchIndexUpdated, */ prepareSearchIndex, prepareSearchIndexPlaceholder } from './prepareSearchIndex.js'
|
||||
|
||||
const __dirname = getDirname(import.meta.url)
|
||||
|
||||
@ -71,16 +71,16 @@ export function searchPlugin({
|
||||
}
|
||||
},
|
||||
|
||||
onPageUpdated: async (app, type, page) => {
|
||||
if (!page?.filePathRelative)
|
||||
return
|
||||
// onPageUpdated: async (app, type, page) => {
|
||||
// if (!page?.filePathRelative)
|
||||
// return
|
||||
|
||||
if (type === 'create' || type === 'update') {
|
||||
await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
|
||||
}
|
||||
else if (type === 'delete') {
|
||||
await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
|
||||
}
|
||||
},
|
||||
// if (type === 'create' || type === 'update') {
|
||||
// await onSearchIndexUpdated(app, { page, isSearchable, searchOptions })
|
||||
// }
|
||||
// else if (type === 'delete') {
|
||||
// await onSearchIndexRemoved(app, { page, isSearchable, searchOptions })
|
||||
// }
|
||||
// },
|
||||
})
|
||||
}
|
||||
|
||||
3348
pnpm-lock.yaml
generated
3348
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,7 @@
|
||||
catalogMode: prefer
|
||||
|
||||
ignoreWorkspaceRootCheck: true
|
||||
|
||||
shamefullyHoist: true
|
||||
shellEmulator: true
|
||||
strictPeerDependencies: false
|
||||
@ -9,85 +11,83 @@ trustPolicyExclude:
|
||||
- cached-factory
|
||||
- memfs
|
||||
- semver
|
||||
|
||||
packages:
|
||||
- docs
|
||||
- theme
|
||||
- cli
|
||||
- plugins/*
|
||||
- examples/*
|
||||
|
||||
patchedDependencies:
|
||||
floating-vue: patches/floating-vue.patch
|
||||
|
||||
catalogs:
|
||||
dev:
|
||||
'@commitlint/cli': ^20.5.0
|
||||
'@commitlint/cli': ^20.5.2
|
||||
'@commitlint/config-conventional': ^20.5.0
|
||||
'@lunariajs/core': ^0.1.1
|
||||
'@pengzhanbo/eslint-config-vue': ^2.2.0
|
||||
'@pengzhanbo/stylelint-config': ^2.2.0
|
||||
'@pengzhanbo/eslint-config-vue': ^2.4.0
|
||||
'@pengzhanbo/stylelint-config': ^2.4.0
|
||||
'@simonwep/pickr': ^1.9.1
|
||||
'@types/express': ^5.0.6
|
||||
'@types/js-yaml': ^4.0.9
|
||||
'@types/less': ^3.0.8
|
||||
'@types/markdown-it': ^14.1.2
|
||||
'@types/minimist': ^1.2.5
|
||||
'@types/node': ^25.5.0
|
||||
'@types/picomatch': ^4.0.2
|
||||
'@types/node': ^25.6.0
|
||||
'@types/picomatch': ^4.0.3
|
||||
'@types/qrcode': ^1.5.6
|
||||
'@types/stylus': ^0.48.43
|
||||
'@types/three': ^0.183.1
|
||||
'@types/three': ^0.184.0
|
||||
'@types/webpack-env': ^1.18.8
|
||||
'@vitest/coverage-v8': ^4.1.2
|
||||
'@vitest/coverage-v8': ^4.1.5
|
||||
bumpp: ^11.0.1
|
||||
commitizen: ^4.3.1
|
||||
conventional-changelog-cli: ^5.0.0
|
||||
cpx2: ^8.0.0
|
||||
conventional-changelog: ^7.2.0
|
||||
conventional-changelog-angular: ^8.3.1
|
||||
cpx2: ^8.0.2
|
||||
cross-env: 7.0.3
|
||||
cz-conventional-changelog: ^3.3.0
|
||||
eslint: ^10.1.0
|
||||
eslint: ^10.2.1
|
||||
http-server: ^14.1.1
|
||||
husky: ^9.1.7
|
||||
less: ^4.6.4
|
||||
lint-staged: ^16.4.0
|
||||
markdown-it: ^14.1.1
|
||||
memfs: ^4.57.1
|
||||
memfs: ^4.57.2
|
||||
mermaid: ^11.14.0
|
||||
minimist: ^1.2.8
|
||||
postcss: ^8.5.8
|
||||
postcss: ^8.5.10
|
||||
rimraf: ^6.1.3
|
||||
stylelint: ^17.6.0
|
||||
stylelint: ^17.9.0
|
||||
stylus: ^0.64.0
|
||||
tsconfig-vuepress: ^7.0.0
|
||||
tsdown: ^0.21.7
|
||||
typescript: ^5.9.3
|
||||
vite: ^8.0.3
|
||||
vitest: ^4.1.2
|
||||
wait-on: ^9.0.4
|
||||
tsdown: ^0.21.10
|
||||
typescript: ^6.0.3
|
||||
vite: ^8.0.10
|
||||
vitest: ^4.1.5
|
||||
wait-on: ^9.0.5
|
||||
peer:
|
||||
'@eslint-community/eslint-utils': ^4.9.1
|
||||
'@iconify/json': ^2.2.458
|
||||
'@iconify/json': ^2.2.466
|
||||
'@mathjax/src': ^4.1.1
|
||||
'@pinyin-pro/data': ^1.3.1
|
||||
'@typescript-eslint/types': ^8.58.0
|
||||
'@typescript-eslint/utils': ^8.58.0
|
||||
'@typescript-eslint/types': ^8.59.0
|
||||
'@typescript-eslint/utils': ^8.59.0
|
||||
artplayer: ^5.4.0
|
||||
dashjs: ^5.1.1
|
||||
gsap: ^3.14.2
|
||||
hls.js: ^1.6.15
|
||||
gsap: ^3.15.0
|
||||
hls.js: ^1.6.16
|
||||
mpegts.js: 1.7.3
|
||||
ogl: ^1.0.11
|
||||
pinyin-pro: ^3.28.0
|
||||
postprocessing: ^6.39.0
|
||||
pinyin-pro: ^3.28.1
|
||||
postprocessing: ^6.39.1
|
||||
pyodide: ^0.29.3
|
||||
sass: ^1.98.0
|
||||
sass-embedded: ^1.98.0
|
||||
sass: ^1.99.0
|
||||
sass-embedded: ^1.99.0
|
||||
swiper: ^12.1.3
|
||||
three: ^0.183.2
|
||||
three: ^0.184.0
|
||||
prod:
|
||||
'@clack/prompts': ^1.2.0
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@iconify/utils': ^3.1.1
|
||||
'@iconify/vue': ^5.0.0
|
||||
'@mdit/plugin-attrs': ^0.25.2
|
||||
'@mdit/plugin-footnote': ^0.23.2
|
||||
@ -96,7 +96,7 @@ catalogs:
|
||||
'@mdit/plugin-sup': ^0.24.2
|
||||
'@mdit/plugin-tab': ^0.24.2
|
||||
'@mdit/plugin-tasklist': ^0.23.2
|
||||
'@pengzhanbo/utils': ^3.3.1
|
||||
'@pengzhanbo/utils': ^3.5.1
|
||||
'@vueuse/core': ^14.2.1
|
||||
'@vueuse/integrations': ^14.2.1
|
||||
cac: ^7.0.0
|
||||
@ -104,17 +104,17 @@ catalogs:
|
||||
chokidar: 5.0.0
|
||||
dayjs: ^1.11.20
|
||||
echarts: ^6.0.0
|
||||
esbuild: ^0.27.5
|
||||
esbuild: ^0.28.0
|
||||
flowchart.ts: ^3.0.1
|
||||
focus-trap: ^8.0.1
|
||||
focus-trap: ^8.1.0
|
||||
gray-matter: ^4.0.3
|
||||
handlebars: ^4.7.9
|
||||
hash-wasm: ^4.12.0
|
||||
image-size: ^2.0.2
|
||||
js-yaml: ^4.1.1
|
||||
katex: ^0.16.44
|
||||
katex: ^0.16.45
|
||||
local-pkg: ^1.1.2
|
||||
lru-cache: ^11.2.7
|
||||
lru-cache: ^11.3.5
|
||||
mark.js: ^8.11.1
|
||||
markdown-it-cjk-friendly: ^2.0.2
|
||||
markdown-it-container: ^4.0.0
|
||||
@ -123,7 +123,7 @@ catalogs:
|
||||
markmap-view: ^0.18.12
|
||||
minisearch: ^7.2.0
|
||||
nano-spawn: ^2.1.0
|
||||
nanoid: ^5.1.7
|
||||
nanoid: ^5.1.9
|
||||
os-locale: ^8.0.0
|
||||
p-map: ^7.0.4
|
||||
package-manager-detector: ^1.6.0
|
||||
@ -134,8 +134,8 @@ catalogs:
|
||||
sort-package-json: ^3.6.1
|
||||
tm-grammars: ^1.31.15
|
||||
tm-themes: ^1.12.2
|
||||
vue: ^3.5.31
|
||||
vue-router: ^5.0.4
|
||||
vue: ^3.5.33
|
||||
vue-router: ^5.0.6
|
||||
vuepress:
|
||||
'@vuepress/bundler-vite': 2.0.0-rc.28
|
||||
'@vuepress/helper': 2.0.0-rc.128
|
||||
@ -160,7 +160,6 @@ catalogs:
|
||||
'@vuepress/plugin-watermark': 2.0.0-rc.128
|
||||
'@vuepress/shiki-twoslash': 2.0.0-rc.128
|
||||
vuepress: 2.0.0-rc.28
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- core-js
|
||||
|
||||
@ -46,10 +46,10 @@ You can install these skills into your project or globally using the `skills` CL
|
||||
|
||||
```bash
|
||||
# Install into your current project (recommended for teams)
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume
|
||||
|
||||
# Install specific skill only
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume --skill vuepress-plume-config
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume --skill vuepress-plume-config
|
||||
```
|
||||
|
||||
2. **Verify Installation:**
|
||||
@ -72,7 +72,7 @@ Claude Code automatically detects skills in the `.claude/skills` directory or th
|
||||
|
||||
```bash
|
||||
# Install for Claude Code specifically
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume -a claude-code
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a claude-code
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
@ -86,7 +86,7 @@ OpenCode supports the open agent skills standard.
|
||||
|
||||
```bash
|
||||
# Install for OpenCode
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume -a opencode
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a opencode
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
@ -46,10 +46,10 @@
|
||||
|
||||
```bash
|
||||
# 安装到当前项目(团队协作推荐)
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume
|
||||
|
||||
# 仅安装特定技能
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume --skill vuepress-plume-config
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume --skill vuepress-plume-config
|
||||
```
|
||||
|
||||
2. **验证安装:**
|
||||
@ -72,7 +72,7 @@ Claude Code 会自动检测 `.claude/skills` 目录或配置的标准 `skills`
|
||||
|
||||
```bash
|
||||
# 专为 Claude Code 安装
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume -a claude-code
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a claude-code
|
||||
```
|
||||
|
||||
**配置:**
|
||||
@ -86,7 +86,7 @@ OpenCode 支持开放代理技能(open agent skills)标准。
|
||||
|
||||
```bash
|
||||
# 为 OpenCode 安装
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-plume -a opencode
|
||||
npx skills add https://github.com/pengzhanbo/vuepress-theme-plume -a opencode
|
||||
```
|
||||
|
||||
**使用:**
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vuepress-theme-plume",
|
||||
"type": "module",
|
||||
"version": "1.0.0-rc.193",
|
||||
"version": "1.0.0-rc.198",
|
||||
"description": "A Blog&Document Theme for VuePress 2.0",
|
||||
"author": "pengzhanbo <q942450674@outlook.com> (https://github.com/pengzhanbo/)",
|
||||
"license": "MIT",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRouter, withBase } from 'vuepress/client'
|
||||
import { useData, useLink } from '../composables/index.js'
|
||||
import { resolveNavLink } from '../utils/index.js'
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string
|
||||
@ -19,6 +20,13 @@ const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))
|
||||
|
||||
const { link, isExternal, isExternalProtocol } = useLink(toRef(props, 'href'), toRef(props, 'target'))
|
||||
|
||||
const resolvedText = computed(() => {
|
||||
if (props.text || isExternal.value || !link.value)
|
||||
return props.text
|
||||
const { text } = resolveNavLink(link.value)
|
||||
return text
|
||||
})
|
||||
|
||||
function linkTo(e: Event) {
|
||||
if (!isExternal.value && link.value) {
|
||||
e.preventDefault()
|
||||
@ -36,9 +44,8 @@ function linkTo(e: Event) {
|
||||
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
|
||||
@click="linkTo($event)"
|
||||
>
|
||||
<slot>
|
||||
{{ text || href }}
|
||||
</slot>
|
||||
<slot>{{ resolvedText || href }}</slot>
|
||||
<slot name="after-text" />
|
||||
<span v-if="isExternal && !noIcon" class="visually-hidden">
|
||||
{{ theme.openNewWindowText || '(Open in new window)' }}
|
||||
</span>
|
||||
|
||||
@ -108,6 +108,7 @@ function onItemInteraction(e: MouseEvent | Event) {
|
||||
:aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${item.text}`"
|
||||
:aria-expanded="!collapsed"
|
||||
tabindex="-1"
|
||||
@click.capture="item.link && toggle()"
|
||||
>
|
||||
<span class="vpi-chevron-right caret-icon" />
|
||||
</button>
|
||||
|
||||
@ -56,7 +56,7 @@ export function useLink(
|
||||
const maybeIsExternal = computed(() => {
|
||||
const link = toValue(href)
|
||||
const rawTarget = toValue(target)
|
||||
if (!link)
|
||||
if (!link || link[0] === '#')
|
||||
return false
|
||||
if (rawTarget === '_blank' || isLinkExternal(link))
|
||||
return true
|
||||
@ -70,8 +70,12 @@ export function useLink(
|
||||
if (!link || maybeIsExternal.value)
|
||||
return link
|
||||
|
||||
if (link[0] === '#')
|
||||
return page.value.path + link
|
||||
|
||||
const currentPath = page.value.filePathRelative ? `/${page.value.filePathRelative}` : undefined
|
||||
const path = resolveRouteFullPath(link, currentPath)
|
||||
|
||||
if (path.includes('#')) {
|
||||
// Compare path + anchor with current route path
|
||||
// Convert to anchor link to avoid page refresh
|
||||
|
||||
@ -189,6 +189,12 @@ const copyPageText = computed(() => {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.vp-page-context-menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.page-context-button {
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
|
||||
@ -211,7 +211,7 @@
|
||||
* Table
|
||||
* -------------------------------------------------------------------------- */
|
||||
.vp-doc table {
|
||||
display: table;
|
||||
display: block;
|
||||
margin: 20px 0;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
|
||||
@ -1,177 +1,81 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
:root {
|
||||
--vp-hint-container-text: var(--vp-c-text-2);
|
||||
--vp-hint-container-font-size: var(--vp-custom-block-font-size);
|
||||
--vp-hint-container-line-height: var(--vp-custom-block-line-height);
|
||||
--vp-hint-container-border: transparent;
|
||||
--vp-hint-container-bg: transparent;
|
||||
--vp-hint-container-link-text: var(--vp-c-brand-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-brand-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-brand-1);
|
||||
--vp-hint-container-code-bg: var(--vp-code-bg);
|
||||
--vp-hint-container-icon: none;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container {
|
||||
padding: 16px 16px 8px;
|
||||
margin: 16px auto;
|
||||
font-size: var(--vp-custom-block-font-size);
|
||||
line-height: var(--vp-custom-block-line-height);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--vp-hint-container-font-size);
|
||||
line-height: var(--vp-hint-container-line-height);
|
||||
color: var(--vp-hint-container-text);
|
||||
background-color: var(--vp-hint-container-bg);
|
||||
border: 1px solid;
|
||||
border-color: var(--vp-hint-container-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.info {
|
||||
color: var(--vp-custom-block-info-text);
|
||||
background-color: var(--vp-custom-block-info-bg);
|
||||
border-color: var(--vp-custom-block-info-border);
|
||||
.vp-doc .hint-container a {
|
||||
color: var(--vp-hint-container-link-text);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.info a,
|
||||
.vp-doc .hint-container.info code {
|
||||
color: var(--vp-c-brand-1);
|
||||
.vp-doc .hint-container code {
|
||||
font-size: var(--vp-custom-block-code-font-size);
|
||||
color: var(--vp-hint-container-code-text);
|
||||
background-color: var(--vp-hint-container-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.info a:hover,
|
||||
.vp-doc .hint-container.info a:hover > code {
|
||||
color: var(--vp-c-brand-2);
|
||||
.vp-doc .hint-container a:hover,
|
||||
.vp-doc .hint-container a:hover > code {
|
||||
color: var(--vp-hint-container-link-hover);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.info code {
|
||||
background-color: var(--vp-custom-block-info-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.note {
|
||||
color: var(--vp-custom-block-note-text);
|
||||
background-color: var(--vp-custom-block-note-bg);
|
||||
border-color: var(--vp-custom-block-note-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.note a,
|
||||
.vp-doc .hint-container.note code {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.note a:hover,
|
||||
.vp-doc .hint-container.note a:hover > code {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.note code {
|
||||
background-color: var(--vp-custom-block-note-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.tip {
|
||||
color: var(--vp-custom-block-tip-text);
|
||||
background-color: var(--vp-custom-block-tip-bg);
|
||||
border-color: var(--vp-custom-block-tip-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.tip a,
|
||||
.vp-doc .hint-container.tip code {
|
||||
color: var(--vp-c-tip-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.tip a:hover,
|
||||
.vp-doc .hint-container.tip a:hover > code {
|
||||
color: var(--vp-c-tip-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.tip code {
|
||||
background-color: var(--vp-custom-block-tip-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.important {
|
||||
color: var(--vp-custom-block-important-text);
|
||||
background-color: var(--vp-custom-block-important-bg);
|
||||
border-color: var(--vp-custom-block-important-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.important a,
|
||||
.vp-doc .hint-container.important code {
|
||||
color: var(--vp-c-important-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.important a:hover,
|
||||
.vp-doc .hint-container.important a:hover > code {
|
||||
color: var(--vp-c-important-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.important code {
|
||||
background-color: var(--vp-custom-block-important-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.warning {
|
||||
color: var(--vp-custom-block-warning-text);
|
||||
background-color: var(--vp-custom-block-warning-bg);
|
||||
border-color: var(--vp-custom-block-warning-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.warning a,
|
||||
.vp-doc .hint-container.warning code {
|
||||
color: var(--vp-c-warning-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.warning a:hover,
|
||||
.vp-doc .hint-container.warning a:hover > code {
|
||||
color: var(--vp-c-warning-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.warning code {
|
||||
background-color: var(--vp-custom-block-warning-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.danger {
|
||||
color: var(--vp-custom-block-danger-text);
|
||||
background-color: var(--vp-custom-block-danger-bg);
|
||||
border-color: var(--vp-custom-block-danger-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.danger a,
|
||||
.vp-doc .hint-container.danger code {
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.danger a:hover,
|
||||
.vp-doc .hint-container.danger a:hover > code {
|
||||
color: var(--vp-c-danger-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.danger code {
|
||||
background-color: var(--vp-custom-block-danger-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.caution {
|
||||
color: var(--vp-custom-block-caution-text);
|
||||
background-color: var(--vp-custom-block-caution-bg);
|
||||
border-color: var(--vp-custom-block-caution-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.caution a,
|
||||
.vp-doc .hint-container.caution code {
|
||||
color: var(--vp-c-caution-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.caution a:hover,
|
||||
.vp-doc .hint-container.caution a:hover > code {
|
||||
color: var(--vp-c-caution-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.caution code {
|
||||
background-color: var(--vp-custom-block-caution-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.details {
|
||||
color: var(--vp-custom-block-details-text);
|
||||
background-color: var(--vp-custom-block-details-bg);
|
||||
border-color: var(--vp-custom-block-details-border);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.details a {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.details a:hover,
|
||||
.vp-doc .hint-container.details a:hover > code {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.details code {
|
||||
background-color: var(--vp-custom-block-details-code-bg);
|
||||
.vp-doc .hint-container-title::before {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
content: "";
|
||||
background-image: var(--vp-hint-container-icon);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100%;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container p {
|
||||
line-height: var(--vp-custom-block-line-height);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container th,
|
||||
.vp-doc .hint-container blockquote > p {
|
||||
font-size: var(--vp-custom-block-font-size);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container th,
|
||||
.vp-doc .hint-container blockquote > p {
|
||||
font-size: var(--vp-custom-block-font-size);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container p + p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container p + p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
@ -191,26 +95,6 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container code {
|
||||
font-size: var(--vp-custom-block-code-font-size);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.vp-doc .hint-container th,
|
||||
.vp-doc .hint-container.vp-doc .hint-container blockquote > p {
|
||||
font-size: var(--vp-custom-block-font-size);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
.vp-doc .hint-container p {
|
||||
line-height: var(--vp-custom-block-line-height);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container p + p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container > :not(summary):first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
@ -219,12 +103,6 @@
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container th,
|
||||
.vp-doc .hint-container blockquote > p {
|
||||
font-size: var(--vp-custom-block-font-size);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vp-doc .hint-container div[class*="language-"] {
|
||||
margin: 16px 0;
|
||||
}
|
||||
@ -262,49 +140,134 @@
|
||||
}
|
||||
}
|
||||
|
||||
.vp-doc .hint-container-title::before {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
content: "";
|
||||
background-image: var(--icon);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100%;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.vp-doc .hint-container-title::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.note .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E");
|
||||
.vp-doc .hint-container:where(.info,.todo) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-info-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-info-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-info-border);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-info-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.info .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E");
|
||||
.vp-doc .hint-container:where(.note,.quote,.cite) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-note-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-note-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-note-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-brand-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-brand-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-brand-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-note-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.tip .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E");
|
||||
.vp-doc .hint-container:where(.tip,.hint) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-tip-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-tip-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-tip-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-tip-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-tip-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-tip-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-tip-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.warning .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
.vp-doc .hint-container:where(.important,.example) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-important-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-important-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-important-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-important-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-important-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-important-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-important-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.danger .hint-container-title::before,
|
||||
.vp-doc .hint-container.caution .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E");
|
||||
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
.vp-doc .hint-container:where(.success,.check,.done) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-success-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-success-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-success-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-success-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-success-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-success-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-success-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%2318794e' d='M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container.important .hint-container-title::before {
|
||||
--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E");
|
||||
.vp-doc .hint-container:where(.warning,.question,.help,.faq) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-warning-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-warning-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-warning-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-warning-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-warning-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-warning-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-warning-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.danger,.caution,.attention,.failure,.fail,.missing,.bug,.error) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-danger-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-danger-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-danger-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-danger-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-danger-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-danger-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-danger-code-bg);
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.caution) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-caution-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-caution-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-caution-border);
|
||||
--vp-hint-container-link-text: var(--vp-c-caution-1);
|
||||
--vp-hint-container-link-hover: var(--vp-c-caution-2);
|
||||
--vp-hint-container-code-text: var(--vp-c-caution-1);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-caution-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.details,.abstract,.summary,.tldr) {
|
||||
--vp-hint-container-text: var(--vp-custom-block-details-text);
|
||||
--vp-hint-container-bg: var(--vp-custom-block-details-bg);
|
||||
--vp-hint-container-border: var(--vp-custom-block-details-border);
|
||||
--vp-hint-container-code-bg: var(--vp-custom-block-details-code-bg);
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.note) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%235da1a2' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10 4H7.2c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C4 5.52 4 6.08 4 7.2v9.6c0 1.12 0 1.68.218 2.108a2 2 0 0 0 .874.874c.427.218.987.218 2.105.218h9.606c1.118 0 1.677 0 2.104-.218c.377-.192.683-.498.875-.874c.218-.428.218-.987.218-2.105V14m-4-9l-6 6v3h3l6-6m-3-3l3-3l3 3l-3 3m-3-3l3 3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.quote,.cite) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.hint) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23299764' stroke-linejoin='round' stroke-width='2' d='M15 19c1.2-3.678 2.526-5.005 6-6c-3.474-.995-4.8-2.322-6-6c-1.2 3.678-2.526 5.005-6 6c3.474.995 4.8 2.322 6 6Zm-8-9c.6-1.84 1.263-2.503 3-3c-1.737-.497-2.4-1.16-3-3c-.6 1.84-1.263 2.503-3 3c1.737.497 2.4 1.16 3 3Zm1.5 10c.3-.92.631-1.251 1.5-1.5c-.869-.249-1.2-.58-1.5-1.5c-.3.92-.631 1.251-1.5 1.5c.869.249 1.2.58 1.5 1.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.todo) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 2048 2048'%3E%3Cpath fill='%232196f3' d='M768 256h1280v128H768zm0 768V896h1280v128zm0 640v-128h1280v128zM256 768q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12m0 240q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12M192 358L467 83l90 90l-365 365L19 365l90-90z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.question,.help) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cg fill='none'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23946300' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 14a1 1 0 1 0 0 2a1 1 0 0 0 0-2m0-9.5a3.625 3.625 0 0 0-3.625 3.625a1 1 0 1 0 2 0a1.625 1.625 0 1 1 2.23 1.51c-.676.27-1.605.962-1.605 2.115V14a1 1 0 1 0 2 0c0-.244.05-.366.261-.47l.087-.04A3.626 3.626 0 0 0 12 6.5'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.faq) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23946300' d='M18.05 6.56a.84.84 0 0 0-.84.84v4.78a.84.84 0 1 0 1.68 0V7.4a.85.85 0 0 0-.84-.84m-6.37 0a.85.85 0 0 0-.84.84v2.43h1.68V7.4a.85.85 0 0 0-.84-.84'/%3E%3Cpath fill='%23946300' d='M22.88 1.94H1.12A1.12 1.12 0 0 0 0 3.06v14.5a1.12 1.12 0 0 0 1.12 1.13h2.6a8.3 8.3 0 0 1-1.48 3.37c3.26.05 5.32-1.21 6.55-3.37h14.09A1.12 1.12 0 0 0 24 17.56V3.06a1.12 1.12 0 0 0-1.12-1.12M7.54 6.56H6a.85.85 0 0 0-.84.84v2.43h1.59a.75.75 0 0 1 0 1.5H5.11v2.44a.75.75 0 1 1-1.5 0V7.4A2.34 2.34 0 0 1 6 5.06h1.54a.75.75 0 1 1 0 1.5M14 13.77a.75.75 0 0 1-1.5 0v-2.44h-1.66v2.44a.75.75 0 0 1-1.5 0V7.4a2.34 2.34 0 1 1 4.66 0Zm6.37-1.59a2.37 2.37 0 0 1-1 1.9l.75.75a.75.75 0 0 1 0 1.06a.75.75 0 0 1-1.06 0l-1.4-1.4a2.34 2.34 0 0 1-2-2.31V7.4a2.34 2.34 0 1 1 4.68 0Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.example) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Ccircle cx='4' cy='7' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='12' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='17' r='1' fill='%237e4cc9'/%3E%3Crect width='14' height='2' x='7' y='11' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='16' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='6' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.attention) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 48 48'%3E%3Cdefs%3E%3Cmask id='SVGkYaseeMx'%3E%3Cg fill='none'%3E%3Cpath fill='%23fff' stroke='%23fff' stroke-linejoin='round' stroke-width='4' d='M24 44a19.94 19.94 0 0 0 14.142-5.858A19.94 19.94 0 0 0 44 24a19.94 19.94 0 0 0-5.858-14.142A19.94 19.94 0 0 0 24 4A19.94 19.94 0 0 0 9.858 9.858A19.94 19.94 0 0 0 4 24a19.94 19.94 0 0 0 5.858 14.142A19.94 19.94 0 0 0 24 44Z'/%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M24 37a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' d='M24 12v16'/%3E%3C/g%3E%3C/mask%3E%3C/defs%3E%3Cpath fill='%23d5393e' d='M0 0h48v48H0z' mask='url(%23SVGkYaseeMx)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.vp-doc .hint-container:where(.bug) {
|
||||
--vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23d5393e' d='m21.55 9.11l-1.84.92v-.02H4.29v.02l-1.84-.92l-.89 1.79l2.47 1.23c0 .12-.02.24-.02.37c0 .51.04 1.01.11 1.5H2.01v2h2.57c.17.5.37.97.6 1.42l-1.87 1.87l1.41 1.41l1.58-1.58c1.23 1.5 2.87 2.52 4.71 2.79v-8.92h2v8.92c1.84-.27 3.48-1.3 4.71-2.79l1.58 1.58l1.41-1.41l-1.87-1.87c.23-.45.43-.93.6-1.42h2.57v-2H19.9c.07-.49.11-.99.11-1.5c0-.13-.02-.25-.02-.37l2.47-1.23l-.89-1.79ZM4.96 8h14.09c-.37-.82-.86-1.56-1.41-2.22l2.07-2.07L18.3 2.3l-2.11 2.11C14.97 3.52 13.54 3 12.01 3s-2.96.52-4.18 1.41L5.72 2.3L4.31 3.71l2.07 2.07c-.55.66-1.04 1.4-1.41 2.22Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@ -442,6 +442,11 @@
|
||||
--vp-custom-block-important-bg: var(--vp-c-important-soft);
|
||||
--vp-custom-block-important-code-bg: var(--vp-c-important-soft);
|
||||
|
||||
--vp-custom-block-success-border: transparent;
|
||||
--vp-custom-block-success-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-success-bg: var(--vp-c-success-soft);
|
||||
--vp-custom-block-success-code-bg: var(--vp-c-success-soft);
|
||||
|
||||
--vp-custom-block-warning-border: transparent;
|
||||
--vp-custom-block-warning-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-warning-bg: var(--vp-c-warning-soft);
|
||||
|
||||
@ -67,6 +67,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [
|
||||
'youtube',
|
||||
'qrcode',
|
||||
'encrypt',
|
||||
'obsidian',
|
||||
'locales',
|
||||
]
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import type { MarkdownMathPluginOptions } from '@vuepress/plugin-markdown-math'
|
||||
import type { PluginConfig } from 'vuepress'
|
||||
import type { MarkdownPowerPluginOptions } from 'vuepress-plugin-md-power'
|
||||
import type { MarkdownOptions, ThemeBuiltinPlugins } from '../../shared/index.js'
|
||||
import { isPlainObject } from '@vuepress/helper'
|
||||
import { isPlainObject } from '@pengzhanbo/utils'
|
||||
import { markdownChartPlugin } from '@vuepress/plugin-markdown-chart'
|
||||
import { markdownHintPlugin } from '@vuepress/plugin-markdown-hint'
|
||||
import { markdownImagePlugin } from '@vuepress/plugin-markdown-image'
|
||||
@ -25,10 +25,11 @@ export function markdownPlugins(pluginOptions: ThemeBuiltinPlugins): PluginConfi
|
||||
const options = getThemeConfig()
|
||||
const plugins: PluginConfig = []
|
||||
let { hint, image, include, math, mdChart, mdPower } = splitMarkdownOptions(options.markdown ?? {})
|
||||
|
||||
const obsidian = isPlainObject(mdPower.obsidian) ? mdPower.obsidian : {}
|
||||
plugins.push(markdownHintPlugin({
|
||||
hint: hint.hint ?? true,
|
||||
alert: hint.alert ?? true,
|
||||
// 如果启用了 obsidian 兼容,则禁用 hint.alert,obsidian callout 已处理 alert
|
||||
alert: mdPower.obsidian === false ? (hint.alert ?? true) : obsidian.callout === false,
|
||||
injectStyles: false,
|
||||
}))
|
||||
|
||||
|
||||
@ -46,12 +46,11 @@ export function resolveMatcherPattern(include?: string | string[], exclude?: str
|
||||
*
|
||||
* @param include - Patterns to include / 要包含的模式
|
||||
* @param exclude - Patterns to exclude / 要排除的模式
|
||||
* @param cwd - Current working directory for relative path matching / 用于相对路径匹配的当前工作目录
|
||||
* @returns Matcher function that tests file paths / 测试文件路径的匹配器函数
|
||||
*/
|
||||
export function createMatcher(include?: string | string[], exclude?: string | string[], cwd?: string): Matcher {
|
||||
export function createMatcher(include?: string | string[], exclude?: string | string[]): Matcher {
|
||||
exclude = ['**/node_modules/**', '**/.vuepress/**', ...toArray(exclude)]
|
||||
const { pattern, ignore } = resolveMatcherPattern(include, exclude)
|
||||
|
||||
return picomatch(pattern, { ignore, cwd })
|
||||
return picomatch(pattern, { ignore })
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@internal/*": ["./docs/.vuepress/.temp/internal/*"],
|
||||
"@theme/*": ["./theme/src/client/components/*"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user