From bfd0c8409c4c55c82f6c55d681f6b3eaafeaddfb Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Sat, 18 Apr 2026 17:01:41 +0800 Subject: [PATCH] feat(plugin-md-power): compat obsidian official markdown syntax (#890) * feat(plugin-md-power): compat obsidian official markdown syntax * chore: tweak * chore: tweak * chore: tweak * chore: tweak --- cli/package.json | 4 +- docs/.vuepress/collections/en/theme-guide.ts | 1 + docs/.vuepress/collections/zh/theme-guide.ts | 1 + docs/.vuepress/theme.ts | 1 + docs/en/guide/markdown/obsidian.md | 338 +++++++++++++ docs/guide/markdown/obsidian.md | 335 +++++++++++++ docs/tsconfig.json | 1 + eslint.config.js | 2 + .../__test__/obsidianCommentPlugin.spec.ts | 72 +++ .../__test__/obsidianEmbedLinkComplex.spec.ts | 446 ++++++++++++++++++ .../__test__/obsidianEmbedLinkPlugin.spec.ts | 124 +++++ .../__test__/obsidianPlugin.spec.ts | 75 +++ .../__test__/obsidianWikiLinkPlugin.spec.ts | 218 +++++++++ plugins/plugin-md-power/package.json | 1 + .../src/node/embed/video/artPlayer.ts | 4 +- .../src/node/obsidian/README.md | 9 + .../src/node/obsidian/comment.ts | 117 +++++ .../src/node/obsidian/embedLink.ts | 301 ++++++++++++ .../src/node/obsidian/index.ts | 27 ++ .../src/node/obsidian/wikiLink.ts | 155 ++++++ plugins/plugin-md-power/src/node/plugin.ts | 2 + .../plugin-md-power/src/node/utils/slugify.ts | 26 + .../plugin-md-power/src/shared/obsidian.ts | 5 + plugins/plugin-md-power/src/shared/pdf.ts | 2 +- plugins/plugin-md-power/src/shared/plugin.ts | 8 + pnpm-lock.yaml | 3 + theme/src/client/components/VPLink.vue | 13 +- theme/src/client/composables/link.ts | 6 +- .../features/components/PageContextMenu.vue | 6 + theme/src/node/detector/fields.ts | 1 + tsconfig.json | 1 - 31 files changed, 2295 insertions(+), 10 deletions(-) create mode 100644 docs/en/guide/markdown/obsidian.md create mode 100644 docs/guide/markdown/obsidian.md create mode 100644 plugins/plugin-md-power/__test__/obsidianCommentPlugin.spec.ts create mode 100644 plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts create mode 100644 plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts create mode 100644 plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts create mode 100644 plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts create mode 100644 plugins/plugin-md-power/src/node/obsidian/README.md create mode 100644 plugins/plugin-md-power/src/node/obsidian/comment.ts create mode 100644 plugins/plugin-md-power/src/node/obsidian/embedLink.ts create mode 100644 plugins/plugin-md-power/src/node/obsidian/index.ts create mode 100644 plugins/plugin-md-power/src/node/obsidian/wikiLink.ts create mode 100644 plugins/plugin-md-power/src/node/utils/slugify.ts create mode 100644 plugins/plugin-md-power/src/shared/obsidian.ts diff --git a/cli/package.json b/cli/package.json index 81da4bfa..69b0cad1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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" }, diff --git a/docs/.vuepress/collections/en/theme-guide.ts b/docs/.vuepress/collections/en/theme-guide.ts index d33acca9..6f2522b5 100644 --- a/docs/.vuepress/collections/en/theme-guide.ts +++ b/docs/.vuepress/collections/en/theme-guide.ts @@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({ 'chat', 'include', 'env', + 'obsidian', ], }, { diff --git a/docs/.vuepress/collections/zh/theme-guide.ts b/docs/.vuepress/collections/zh/theme-guide.ts index f0ee78fb..a8d8fdb4 100644 --- a/docs/.vuepress/collections/zh/theme-guide.ts +++ b/docs/.vuepress/collections/zh/theme-guide.ts @@ -69,6 +69,7 @@ export const themeGuide: ThemeCollectionItem = defineCollection({ 'chat', 'include', 'env', + 'obsidian', ], }, { diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts index 5753c251..7202c2f3 100644 --- a/docs/.vuepress/theme.ts +++ b/docs/.vuepress/theme.ts @@ -58,6 +58,7 @@ export const theme: Theme = plumeTheme({ jsfiddle: true, demo: true, encrypt: true, + obsidian: true, npmTo: ['pnpm', 'yarn', 'npm'], repl: { go: true, diff --git a/docs/en/guide/markdown/obsidian.md b/docs/en/guide/markdown/obsidian.md new file mode 100644 index 00000000..d321c219 --- /dev/null +++ b/docs/en/guide/markdown/obsidian.md @@ -0,0 +1,338 @@ +--- +title: Obsidian Compatibility +icon: simple-icons:obsidian +createTime: 2026/04/17 21:56:55 +permalink: /en/guide/markdown/obsidian/ +--- + +## Overview + +The theme provides compatibility support for Obsidian's official Markdown extension syntax through the `vuepress-plugin-md-power` plugin, +enabling Obsidian users to write documentation using familiar syntax. + +Currently supported Obsidian extension syntax includes: + +- [Wiki Links](#wiki-links) - Syntax for inter-page linking +- [Embeds](#embeds) - Embed content from other files into the current page +- [Comments](#comments) - Add comments visible only during editing + +::: warning No plans to support extension syntax provided by Obsidian's third-party community plugins +::: + +## Wiki Links + +Wiki Links are syntax for linking to other notes in Obsidian. + +### Syntax + +```md +[[filename]] +[[filename#heading]] +[[filename#heading#subheading]] +[[filename|alias]] +[[filename#heading|alias]] +``` + +### Filename Search Rules + +When using Wiki Links, filenames are matched according to the following rules: + +**Match Priority:** + +1. **Page Title** - Priority matching against page titles +2. **Full Path** - Exact match against file paths +3. **Fuzzy Match** - Match filenames at the end of paths + +**Path Resolution Rules:** + +- **Relative paths** (starting with `.`): Resolved relative to the current file's directory +- **Absolute paths** (not starting with `.`): Searched throughout the document tree, with shortest path taking precedence +- **Directory form** (ending with `/`): Matches `README.md` or `index.html` within that directory + +**Example:** + +Assuming the following document structure: + +```txt +docs/ +├── README.md (title: "Home") +├── guide/ +│ ├── README.md (title: "Guide") +│ └── markdown/ +│ └── obsidian.md +``` + +In `docs/guide/markdown/obsidian.md`: + +| Syntax | Match Result | +| ------------ | ------------------------------------------------------- | +| `[[Home]]` | Matches `docs/README.md` (via title) | +| `[[Guide]]` | Matches `docs/guide/README.md` (via title) | +| `[[./]]` | Matches `docs/guide/markdown/README.md` (relative path) | +| `[[../]]` | Matches `docs/guide/README.md` (parent directory) | +| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) | + +### Examples + +**External Links:** + +**Input:** + +```md +[[https://example.com|External Link]] +``` + +**Output:** + +[[https://example.com|External Link]] + +--- + +**Internal Anchor Links:** + +**Input:** + +```md +[[QR Code]] +[[npm-to]] +[[guide/markdown/math]] +[[#Wiki Links]] +[[file-tree#configuration]] +``` + +**Output:** + +[[QR Code]] + +[[npm-to]] + +[[guide/markdown/math]] + +[[#Wiki Links]] + +[[file-tree#configuration]] + +[Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore} + +## Embeds + +The embed syntax allows you to insert other file resources into the current page. + +### Syntax + +```md +![[filename]] +![[filename#heading]] +![[filename#heading#subheading]] +``` + +Filename search rules are the same as [Wiki Links](#filename-search-rules). + +::: info Resources starting with `/` or having no path prefix like `./` are loaded from the `public` directory +::: + +### Image Embeds + +**Syntax:** + +```md +![[image.png]] +![[image.png|300]] +![[image.png|300x200]] +``` + +Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm` + +**Input:** + +```md +![[images/custom-hero.jpg]] +``` + +**Output:** + +![[images/custom-hero.jpg]] + +### PDF Embeds + +> [!NOTE] +> PDF embeds require the `markdown.pdf` plugin to be enabled for proper functionality. + +**Syntax:** + +```md +![[document.pdf]] +![[document.pdf#page=1]] +![[document.pdf#page=1#height=300]] +``` + +--- + +### Audio Embeds + +> [!note] +> Audio embeds require the file path to be correct and the file to exist in the document directory. + +**Input:** + +```md +![[audio.mp3]] +``` + +**Output:** + +![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]] + +Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc` + +--- + +### Video Embeds + +> [!note] +> Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality. + +**Input:** + +```md +![[video.mp4]] +``` + +**Output:** + +![[https://artplayer.org/assets/sample/video.mp4]] + +Supported formats: `mp4`, `webm`, `mov`, etc. + +--- + +### Content Fragment Embeds + +Content fragments under a specified heading can be embedded using `#heading`: + +**Input:** + +```md +![[my-note]] +![[my-note#heading-one]] +![[my-note#heading-one#subheading]] +``` + +[Obsidian Official - Embeds](https://obsidian.md/en/help/embeds){.readmore} +[Obsidian Official - File Formats](https://obsidian.md/en/help/file-formats){.readmore} + +## Comments + +Content wrapped in `%%` is treated as a comment and will not be rendered on the page. + +### Syntax + +**Inline Comments:** + +```md +This is an %%inline comment%% example. +``` + +**Block Comments:** + +```md +%% +This is a block comment. +It can span multiple lines. +%% +``` + +### Examples + +**Inline Comments:** + +**Input:** + +```md +This is an %%inline comment%% example. +``` + +**Output:** + +This is an %%inline comment%% example. + +--- + +**Block Comments:** + +**Input:** + +```md +Content before the comment + +%% +This is a block comment. + +It can span multiple lines. +%% + +Content after the comment +``` + +**Output:** + +Content before the comment + +%% +This is a block comment. + +It can span multiple lines. +%% + +Content after the comment + +> Related Documentation: [Obsidian Official - Comments](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B) + +## Configuration + +You can enable or disable these plugins in the theme configuration: + +```ts title=".vuepress/config.ts" +export default defineUserConfig({ + theme: plumeTheme({ + plugins: { + mdPower: { + // Obsidian compatibility plugin configuration + obsidian: { + wikiLink: true, // Wiki Links + embedLink: true, // Embeds + comment: true, // Comments + }, + pdf: true, // PDF embed functionality + artPlayer: true, // Video embed functionality + } + } + }) +}) +``` + +### Configuration Options + +:::: field-group + +::: field name="wikiLink" type="boolean" default="true" optional +Enable Wiki Links syntax +::: + +::: field name="embedLink" type="boolean" default="true" optional +Enable embed content syntax +::: + +::: field name="comment" type="boolean" default="true" optional +Enable comment syntax +::: + +:::: + +## Notes + +- These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality +- Some Obsidian-specific features (such as the graph view for internal links, bidirectional links, etc.) are outside the scope of this support +- When embedding content, the embedded page also participates in the theme's build process +- PDF embeds require the `pdf` plugin to be enabled simultaneously +- Video embeds require the `artPlayer` plugin to be enabled simultaneously diff --git a/docs/guide/markdown/obsidian.md b/docs/guide/markdown/obsidian.md new file mode 100644 index 00000000..3033a34e --- /dev/null +++ b/docs/guide/markdown/obsidian.md @@ -0,0 +1,335 @@ +--- +title: Obsidian 兼容 +icon: simple-icons:obsidian +createTime: 2026/04/17 21:56:55 +permalink: /guide/markdown/obsidian/ +--- + +## 概述 + +主题通过 `vuepress-plugin-md-power` 插件提供对 Obsidian 官方 Markdown 扩展语法的兼容性支持,使 Obsidian 用户能够以熟悉的语法撰写文档。 + +当前已支持的 Obsidian 扩展语法包括: + +- [Wiki 链接](#wiki-链接) - 页面间相互链接的语法 +- [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面 +- [注释](#注释) - 添加仅在编辑时可见的注释 + +::: warning 不计划对 obsidian 社区的第三方插件提供的扩展语法进行支持 +::: + +## Wiki 链接 + +Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。 + +### 语法 + +```md +[[文件名]] +[[文件名#标题]] +[[文件名#标题#子标题]] +[[文件名|别名]] +[[文件名#标题|别名]] +``` + +### 文件名搜索规则 + +当使用 Wiki 链接时,文件名会按照以下规则进行搜索匹配: + +**匹配优先级:** + +1. **页面标题** - 优先匹配页面的标题 +2. **完整路径** - 精确匹配文件路径 +3. **模糊匹配** - 匹配路径结尾的文件名 + +**路径解析规则:** + +- **相对路径**(以 `.` 开头):相对于当前文件所在目录解析 +- **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径 +- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md` 或 `index.html` + +**示例:** + +假设文档结构如下: + +```txt +docs/ +├── README.md (title: "首页") +├── guide/ +│ ├── README.md (title: "指南") +│ └── markdown/ +│ └── obsidian.md +``` + +在 `docs/guide/markdown/obsidian.md` 中: + +| 语法 | 匹配结果 | +| ------------ | ------------------------------------------------ | +| `[[首页]]` | 匹配 `docs/README.md`(通过标题) | +| `[[指南]]` | 匹配 `docs/guide/README.md`(通过标题) | +| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) | +| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) | +| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) | + +### 示例 + +**外部链接:** + +**输入:** + +```md +[[https://example.com|外部链接]] +``` + +**输出:** + +[[https://example.com|外部链接]] + +**内部锚点链接:** + +**输入:** + +```md +[[二维码]] +[[npm-to]] +[[guide/markdown/math]] +[[#Wiki 链接]] +[[file-tree#配置]] +``` + +**输出:** + +[[二维码]] + +[[npm-to]] + +[[guide/markdown/math]] + +[[#Wiki 链接]] + +[[file-tree#配置]] + +[Obsidian 官方 - **Wiki Links**](https://obsidian.md/zh/help/links){.readmore} + +## 嵌入内容 + +嵌入语法允许你将其他文件资源插入到当前页面中。 + +### 语法 + +```md +![[文件名]] +![[文件名#标题]] +![[文件名#标题#子标题]] +``` + +文件名搜索规则与 [Wiki 链接](#文件名搜索规则) 相同。 + +::: info 以 `/` 开头或 无路径前缀如 `./` 形式的,从 `public` 目录中加载资源 +::: + +### 图片嵌入 + +**语法:** + +```md +![[image.png]] +![[image.png|300]] +![[image.png|300x200]] +``` + +支持格式:`jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm` + +**输入:** + +```md +![[images/custom-hero.jpg]] +``` + +**输出:** + +![[images/custom-hero.jpg]] + +### PDF 嵌入 + +> [!NOTE] +> PDF 嵌入需要启用 `markdown.pdf` 插件才能正常工作。 + +**语法:** + +```md +![[document.pdf]] +![[document.pdf#page=1]] +![[document.pdf#page=1#height=300]] +``` + +--- + +### 音频嵌入 + +> [!note] +> 音频嵌入需要确保文件路径正确,文件存在于文档目录中。 + +**输入:** + +```md +![[audio.mp3]] +``` + +**输出:** + +![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]] + +支持格式:`mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc` + +--- + +### 视频嵌入 + +> [!note] +> 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。 + +**输入:** + +```md +![[video.mp4]] +``` + +**输出:** + +![[https://artplayer.org/assets/sample/video.mp4]] + +支持格式:`mp4`, `webm`, `mov` 等 + +--- + +### 内容片段嵌入 + +通过 `#标题` 可以嵌入指定标题下的内容片段: + +**输入:** + +```md +![[我的笔记]] +![[我的笔记#标题一]] +![[我的笔记#标题一#子标题]] +``` + +[Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore} +[Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore} + +## 注释 + +使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。 + +### 语法 + +**行内注释:** + +```md +这是一个 %%行内注释%% 示例。 +``` + +**块级注释:** + +```md +%% +这是一个块级注释。 +可以跨越多行。 +%% +``` + +### 示例 + +**行内注释:** + +**输入:** + +```md +这是一个 %%行内注释%% 示例。 +``` + +**输出:** + +这是一个 %%行内注释%% 示例。 + +--- + +**块级注释:** + +**输入:** + +```md +注释之前的内容 + +%% +这是一个块级注释。 + +可以跨越多行。 +%% + +注释之后的内容 +``` + +**输出:** + +注释之前的内容 + +%% +这是一个块级注释。 + +可以跨越多行。 +%% + +注释之后的内容 + +[Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore} + +## 配置 + +你可以在主题配置中启用或禁用这些插件: + +```ts title=".vuepress/config.ts" +export default defineUserConfig({ + theme: plumeTheme({ + plugins: { + mdPower: { + // Obsidian 兼容插件配置 + obsidian: { + wikiLink: true, // Wiki 链接 + embedLink: true, // 嵌入内容 + comment: true, // 注释 + }, + pdf: true, // PDF 嵌入功能 + artPlayer: true, // 视频嵌入功能 + } + } + }) +}) +``` + +### 配置项 + +:::: field-group + +::: field name="wikiLink" type="boolean" default="true" optional +启用 Wiki 链接语法 +::: + +::: field name="embedLink" type="boolean" default="true" optional +启用嵌入内容语法 +::: + +::: field name="comment" type="boolean" default="true" optional +启用注释语法 +::: + +:::: + +## 注意事项 + +- 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能 +- 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内 +- 嵌入内容时,被嵌入的页面也会参与主题的构建过程 +- PDF 嵌入需要同时启用 `pdf` 插件 +- 视频嵌入需要同时启用 `artPlayer` 插件 diff --git a/docs/tsconfig.json b/docs/tsconfig.json index fb07c0c9..a5bf43aa 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "~/themes/*": ["./.vuepress/themes/*"], "~/components/*": ["./.vuepress/themes/components/*"], diff --git a/eslint.config.js b/eslint.config.js index fee0c4ba..dd467cb5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/plugins/plugin-md-power/__test__/obsidianCommentPlugin.spec.ts b/plugins/plugin-md-power/__test__/obsidianCommentPlugin.spec.ts new file mode 100644 index 00000000..1c00c1e6 --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianCommentPlugin.spec.ts @@ -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(' { + 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') + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts b/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts new file mode 100644 index 00000000..54e50377 --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts @@ -0,0 +1,446 @@ +import type { App } from 'vuepress' +import type { MarkdownEnv } from 'vuepress/markdown' +import MarkdownIt from 'markdown-it' +import { describe, expect, it, vi } from 'vitest' +import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js' + +function createMockApp(pages: App['pages'] = []): App { + return { + pages, + } as App +} + +function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { + return { + filePathRelative, + base: '/', + links: [], + importedFiles: [], + } +} + +function createMarkdownWithMockRules() { + return MarkdownIt({ html: true }).use((md) => { + md.block.ruler.before('code', 'import_code', () => false) + md.renderer.rules.import_code = () => '' + }) +} + +vi.mock('gray-matter', () => ({ + default: vi.fn(content => ({ + content: content.replace(/^---[\s\S]*?---\n?/, ''), + data: {}, + })), +})) + +describe('embedLinkPlugin - internal markdown embed', () => { + it('should embed entire markdown file when no heading specified', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction + +This is the guide content. + +## Getting Started + +Step 1, Step 2, Step 3. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide]]', createMockEnv('test.md')) + expect(result).toContain(' { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction + +This is intro. + +## Getting Started + +Steps for getting started. + +## Advanced + +Advanced content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md')) + expect(result).toContain('Steps for getting started') + expect(result).not.toContain('Advanced content') + }) + + it('should embed nested heading content', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction + +## Installation + +### Prerequisites + +Software requirements. + +### Download + +Download links. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Installation#Download]]', createMockEnv('test.md')) + expect(result).toContain('Download links') + expect(result).not.toContain('Prerequisites') + }) + + it('should handle heading with id syntax', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction {#intro} + +Intro content. + +## Getting Started {#getting-started} + +Start content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Getting Started]]', createMockEnv('test.md')) + expect(result).toContain('Start content') + }) + + it('should preserve container blocks within embedded content', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction + +::: warning +This is a warning block. +::: + +Some text. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide]]', createMockEnv('test.md')) + expect(result).toContain('::: warning') + expect(result).toContain('This is a warning block') + }) + + it('should return empty string when heading not found', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction + +Content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#NonExistent]]', createMockEnv('test.md')) + expect(result.trim()).toBe('') + }) + + it('should add importedFiles to env when embedding', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `# Guide\n\nContent.`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const env = createMockEnv('test.md') + md.render('![[Guide]]', env) + expect(env.importedFiles).toContain('docs/guide.md') + }) + + it('should handle heading with special characters', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Guide (中文) {#zh} + +Chinese content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Guide (中文)]]', createMockEnv('test.md')) + expect(result).toContain('Chinese content') + }) + + it('should handle heading with class syntax', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Introduction {.intro .basic} + +Intro content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Introduction]]', createMockEnv('test.md')) + expect(result).toContain('Intro content') + }) + + it('should handle multiple consecutive container blocks', async () => { + const mockPage = { + path: '/docs/guide/', + filePathRelative: 'docs/guide.md', + title: 'Guide', + content: `--- +title: Guide +--- + +# Section + +::: info +Info block. +::: + +::: warning +Warning block. +::: + +More text. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Guide#Section]]', createMockEnv('test.md')) + expect(result).toContain('Info block') + expect(result).toContain('Warning block') + expect(result).toContain('More text') + }) + + it('should handle frontmatter in embedded file', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `--- +title: Actual Title +author: Test Author +--- + +# Actual Title + +Page content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Page]]', createMockEnv('test.md')) + expect(result).toContain('Page content') + expect(result).not.toContain('author') + }) +}) + +describe('extractContentByHeadings', () => { + it('should return full content when no headings specified', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `# Title\n\nContent here.`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Page]]', createMockEnv('test.md')) + expect(result).toContain('Content here') + }) + + it('should restart heading search when encountering same text at lower level', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `--- +--- + +# Section + +## Subsection + +Subsection content. + +# Section + +## Another + +Another content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Page#Section#Another]]', createMockEnv('test.md')) + expect(result).toContain('Another content') + expect(result).not.toContain('Subsection content') + }) + + it('should reset search when encountering different heading at lower level', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `--- +--- + +# Section + +## Subsection + +Subsection content. + +# Other + +## Content + +Other content. +`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + // Search for "Section#Content" - after matching "Section" and failing to find "Content" + // under "Subsection" (which is level 2 > 1), we encounter "Other" at level 1 + // heading.level (1) <= currentLevel (2), and "Other" !== "Section" + // So we enter the else branch at lines 268-270: headingPointer = 0, currentLevel = 0 + const result = md.render('![[Page#Section#Content]]', createMockEnv('test.md')) + expect(result.trim()).toBe('') + }) + + it('should extract content between sibling headings', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `# Title\n\nIntro.\n\n## Section1\n\nSection 1 content.\n\n## Section2\n\nSection 2 content.\n`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Page#Section1]]', createMockEnv('test.md')) + expect(result).toContain('Section 1 content') + expect(result).not.toContain('Section 2 content') + }) + + it('should handle deep nested headings', async () => { + const mockPage = { + path: '/docs/page/', + filePathRelative: 'docs/page.md', + title: 'Page', + content: `# H1\n\n## H2a\n\n### H3a\n\nH3a content.\n\n### H3b\n\nH3b content.\n\n## H2b\n\nH2b content.\n`, + markdownEnv: { base: '/' }, + } + + const mockApp = createMockApp([mockPage] as unknown as App['pages']) + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + const result = md.render('![[Page#H2a#H3b]]', createMockEnv('test.md')) + expect(result).toContain('H3b content') + expect(result).not.toContain('H3a content') + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts b/plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts new file mode 100644 index 00000000..1dd7a227 --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts @@ -0,0 +1,124 @@ +import type { App } from 'vuepress' +import type { MarkdownEnv } from 'vuepress/markdown' +import MarkdownIt from 'markdown-it' +import { describe, expect, it } from 'vitest' +import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js' + +function createMockApp(pages: App['pages'] = []): App { + return { + pages, + } as App +} + +function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { + return { + filePathRelative, + base: '/', + links: [], + importedFiles: [], + } +} + +function createMarkdownWithMockRules() { + return MarkdownIt({ html: true }).use((md) => { + md.block.ruler.before('code', 'import_code', () => false) + md.renderer.rules.import_code = () => '' + }) +} + +describe('embedLinkPlugin', () => { + const mockApp = createMockApp() + const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) + + it('should render image embed', () => { + const result = md.render('![[image.png]]') + expect(result).toContain(' { + const result = md.render('![[image.png|300]]') + expect(result).toContain(' { + const result = md.render('![[image.png|300x200]]') + expect(result).toContain(' { + const result = md.render('![[audio.mp3]]') + expect(result).toContain(' { + const result = md.render('![[video.mp4]]') + expect(result).toContain(' { + const result = md.render('![[document.pdf]]') + expect(result).toContain(' { + const result = md.render('![[document.pdf#page=1]]') + expect(result).toContain('page="1"') + }) + + it('should render external http link as anchor', () => { + const result = md.render('![[https://example.com/file]]') + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('![[./image.png]]', env) + expect(result).toContain(' { + const result = md.render('![[/images/cover.jpg]]') + expect(result).toContain(' { + const result = md.render('![[file.unknown]]') + expect(result).toContain(' { + const result = md.render('![[image.png]') + expect(result).toContain('![[image.png]') + }) + + it('should render markdown file embed as link when page not found', () => { + const result = md.render('![[nonexistent.md]]') + expect(result).toContain(' { + const result = md.render('![[nonexistent.md#heading1#heading2]]') + expect(result).toContain(' { + const result = md.render('![[]]') + expect(result).toContain('![[]]') + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts b/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts new file mode 100644 index 00000000..2b754e0a --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts @@ -0,0 +1,75 @@ +import type { App } from 'vuepress' +import MarkdownIt from 'markdown-it' +import { describe, expect, it } from 'vitest' +import { obsidianPlugin } from '../src/node/obsidian/index.js' + +function createMockApp(pages: App['pages'] = []): App { + return { + pages, + } as App +} + +function createMarkdownWithMockRules() { + return MarkdownIt({ html: true }).use((md) => { + md.block.ruler.before('code', 'import_code', () => false) + md.renderer.rules.import_code = () => '' + }) +} + +describe('obsidianPlugin', () => { + it('should enable all plugins by default', () => { + const md = createMarkdownWithMockRules() + const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages']) + obsidianPlugin(md, mockApp, {}) + + const embedResult = md.render('![[image.png]]') + expect(embedResult).toContain(' { + const md = createMarkdownWithMockRules() + const mockApp = createMockApp() + obsidianPlugin(md, mockApp, { obsidian: { wikiLink: false } }) + + const wikiResult = md.render('[[Page]]') + expect(wikiResult).not.toContain(' { + const md = createMarkdownWithMockRules() + const mockApp = createMockApp() + obsidianPlugin(md, mockApp, { obsidian: false }) + + const result = md.render('![[image.png]]') + expect(result).not.toContain(' { + const md = createMarkdownWithMockRules() + const mockApp = createMockApp() + obsidianPlugin(md, mockApp, { obsidian: { embedLink: false } }) + + const result = md.render('![[image.png]]') + expect(result).not.toContain(' { + const md = createMarkdownWithMockRules() + const mockApp = createMockApp() + obsidianPlugin(md, mockApp, { obsidian: { comment: false } }) + + const embedResult = md.render('![[image.png]]') + expect(embedResult).toContain(' { + const mockApp = createMockApp([ + { + path: '/docs/getting-started/', + filePathRelative: 'docs/getting-started/README.md', + title: 'Getting Started', + }, + { + path: '/docs/guide/intro/', + filePathRelative: 'docs/guide/intro.md', + title: 'Introduction', + }, + { + path: '/api/utils/', + filePathRelative: 'api/utils.md', + title: 'Utils', + }, + ] as App['pages']) + + const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin, mockApp) + + it('should render internal wiki link to existing page', () => { + const env = createMockEnv('docs/page.md') + const result = md.render('[[Getting Started]]', env) + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('[[Getting Started|Quick Start]]', env) + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('[[Introduction#Installation]]', env) + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('[[Introduction#Installation|Install Guide]]', env) + expect(result).toContain(' { + const result = md.render('[[https://example.com]]') + expect(result).toContain(' { + const result = md.render('[[https://example.com|Example Site]]') + expect(result).toContain('>Example Site<') + expect(result).toContain('href="https://example.com"') + }) + + it('should render external link with heading and alias', () => { + const result = md.render('[[https://example.com/page#section|Go to Section]]') + expect(result).toContain('>Go to Section<') + expect(result).toContain('href="https://example.com/page#section"') + }) + + it('should render external link with heading but no alias', () => { + const result = md.render('[[https://example.com/page#section]]') + expect(result).toContain('href="https://example.com/page#section"') + expect(result).toContain('https://example.com/page > section') + }) + + it('should render internal hash link for empty filename', () => { + const env = createMockEnv('docs/page.md') + const result = md.render('[[#anchor]]', env) + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('[[#anchor|Back to Top]]', env) + expect(result).toContain('>Back to Top<') + expect(result).toContain('href="#anchor"') + }) + + it('should render internal hash link with titles but no alias', () => { + const env = createMockEnv('docs/page.md') + const result = md.render('[[#anchor1#anchor2]]', env) + expect(result).toContain('href="#anchor2"') + expect(result).toContain('> anchor1 > anchor2') + }) + + it('should render relative path wiki link as anchor when not found', () => { + const env = createMockEnv('docs/page.md') + const result = md.render('[[../api/other.md]]', env) + expect(result).toContain(' { + const env = createMockEnv('docs/page.md') + const result = md.render('[[../api/other.md|View API]]', env) + expect(result).toContain('>View API<') + expect(result).toContain('href="/api/other.md"') + }) + + it('should render relative path wiki link with heading but no alias', () => { + const env = createMockEnv('docs/page.md') + const result = md.render('[[../api/other.md#section]]', env) + expect(result).toContain('href="/api/other.md#section"') + expect(result).toContain('../api/other.md > section') + }) + + it('should add to links array in env', () => { + const env = createMockEnv('docs/page.md') + md.render('[[Utils]]', env) + expect(env.links).toBeDefined() + expect(env.links!.length).toBeGreaterThan(0) + }) + + it('should not parse wiki link without closing bracket', () => { + const result = md.render('[[Page') + expect(result).toContain('[[Page') + }) + + it('should not parse empty wiki link', () => { + const result = md.render('[[]]') + expect(result).toContain('[[]]') + }) +}) + +describe('findFirstPage', () => { + const mockApp = createMockApp([ + { path: '/', filePathRelative: 'README.md', title: 'Home' }, + { path: '/docs/guide/', filePathRelative: 'docs/guide/README.md', title: 'Guide' }, + { path: '/docs/api/', filePathRelative: 'docs/api.md', title: 'API' }, + { path: '/docs/config/', filePathRelative: 'docs/config/index.md', title: 'Config' }, + ] as App['pages']) + + it('should find page by exact title', () => { + const result = findFirstPage(mockApp, 'Guide', 'any/path.md') + expect(result?.title).toBe('Guide') + }) + + it('should find page by exact file path', () => { + const result = findFirstPage(mockApp, 'docs/api.md', 'any/path.md') + expect(result?.title).toBe('API') + }) + + it('should find page by exact title with path-like name', () => { + const result = findFirstPage(mockApp, 'docs/config/index', 'any/path.md') + expect(result?.title).toBe('Config') + }) + + it('should find folder index by trailing slash', () => { + const result = findFirstPage(mockApp, 'docs/guide/', 'any/path.md') + expect(result?.title).toBe('Guide') + }) + + it('should find page without extension', () => { + const result = findFirstPage(mockApp, 'docs/api', 'any/path.md') + expect(result?.title).toBe('API') + }) + + it('should find page by fuzzy match with folder path ending in slash', () => { + const app = createMockApp([ + { path: '/docs/features/', filePathRelative: 'docs/features/README.md', title: 'Features' }, + ] as App['pages']) + const result = findFirstPage(app, 'docs/features/', 'any/path.md') + expect(result?.title).toBe('Features') + }) + + it('should find page by fuzzy match ending with index.html', () => { + const app = createMockApp([ + { path: '/docs/guide/', filePathRelative: 'docs/guide/index.html', title: 'Guide' }, + ] as App['pages']) + const result = findFirstPage(app, 'docs/guide/', 'any/path.md') + expect(result?.title).toBe('Guide') + }) + + it('should return undefined when page not found', () => { + const result = findFirstPage(mockApp, 'nonexistent', 'any/path.md') + expect(result).toBeUndefined() + }) + + it('should find page by data.title fallback', () => { + const app = createMockApp([ + { path: '/test/', filePathRelative: 'test.md', data: { title: 'Data Title' } }, + ] as unknown as App['pages']) + const result = findFirstPage(app, 'Data Title', 'any/path.md') + expect(result?.path).toBe('/test/') + }) +}) diff --git a/plugins/plugin-md-power/package.json b/plugins/plugin-md-power/package.json index 2bfdad40..6ebe4e03 100644 --- a/plugins/plugin-md-power/package.json +++ b/plugins/plugin-md-power/package.json @@ -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", diff --git a/plugins/plugin-md-power/src/node/embed/video/artPlayer.ts b/plugins/plugin-md-power/src/node/embed/video/artPlayer.ts index 91389d13..03181b06 100644 --- a/plugins/plugin-md-power/src/node/embed/video/artPlayer.ts +++ b/plugins/plugin-md-power/src/node/embed/video/artPlayer.ts @@ -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 = (md) => { createEmbedRuleBlock(md, { @@ -51,7 +51,7 @@ export const artPlayerPlugin: PluginWithOptions = (md) => { }) } -function checkSupportType(type?: string) { +export function checkSupportType(type?: string) { if (!type) return diff --git a/plugins/plugin-md-power/src/node/obsidian/README.md b/plugins/plugin-md-power/src/node/obsidian/README.md new file mode 100644 index 00000000..8fa0f6f2 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/README.md @@ -0,0 +1,9 @@ +# 说明 + +兼容部分 obsidian 的 markdown 扩展语法。 + +**仅计划支持 obsidian 的官方扩展语法**。 + +- [x] wikiLink: `[[文件名]]` `[[文件名#标题]]` `[[文件名#标题#标题]]` `[[文件名#标题|别名]]` +- [x] embedLink: `![[文件名]]` `![[文件名#标题]]` `![[文件名#标题#标题]]` +- [x] comment: `%%注释%%` diff --git a/plugins/plugin-md-power/src/node/obsidian/comment.ts b/plugins/plugin-md-power/src/node/obsidian/comment.ts new file mode 100644 index 00000000..ab2b0944 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/comment.ts @@ -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 = () => '' +} diff --git a/plugins/plugin-md-power/src/node/obsidian/embedLink.ts b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts new file mode 100644 index 00000000..ac551218 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts @@ -0,0 +1,301 @@ +/** + * Embed Link 是属于 obsidian 官方扩展的 markdown 语法 + * + * - ![[文件名]] ![[文件名#标题]] ![[文件名#标题#标题]] + * - ![[资源链接]]: + * - ![[图片]] ![[图片|width]] ![[图片|widthxheight]] + * - ![[pdf]] ![[pdf#page=1#height=300]] + * - ![[音频]] + * - ![[视频]] + * + * @see - https://obsidian.md/zh/help/embeds + * @see - https://obsidian.md/zh/help/file-formats + * + * 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。 + */ + +import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' +import type { App } from 'vuepress' +import type { Markdown, MarkdownEnv } from 'vuepress/markdown' +import grayMatter from 'gray-matter' +import Token from 'markdown-it/lib/token.mjs' +import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared' +import { hash, path } from 'vuepress/utils' +import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js' +import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js' +import { parseRect } from '../utils/parseRect.js' +import { slugify } from '../utils/slugify.js' +import { findFirstPage } from './wikiLink.js' + +interface EmbedLinkMeta { + filename: string + hashes: string[] + settings: string +} + +const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm'] +const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc'] +const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`) + +const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine] + const max = state.eMarks[startLine] + + // - ![[]] + if (max - start < 6) + return false + + // 是否以 `![[` 开头 + if ( + state.src.charCodeAt(start) !== 0x21 // \! + || state.src.charCodeAt(start + 1) !== 0x5B // [ + || state.src.charCodeAt(start + 2) !== 0x5B // [ + ) { + return false + } + + const line = state.src.slice(start, max).trim() + // 是否以 `]]` 结尾 + if ( + line.charCodeAt(line.length - 1) !== 0x5D // ] + || line.charCodeAt(line.length - 2) !== 0x5D // ] + ) { + return false + } + + /* istanbul ignore if -- @preserve */ + if (silent) + return true + + // ![[xxxx]] + // ^^^^ <- content + const content = line.slice(3, -2).trim() + + const [file, settings] = content.split('|').map(x => x.trim()) + const [filename, ...hashes] = file.trim().split('#').map(x => x.trim()) + const extname = path.extname(filename).toLowerCase() + + // 渲染为 图片 + if (EXTENSION_IMAGES.includes(extname)) { + const token = state.push('image', 'img', 1) + token.content = filename + token.attrSet('src', resolveFilenameToAssetPath(filename)) + token.attrSet('alt', filename) + if (settings) { + const [width, height] = settings.split('x').map(x => x.trim()) + const styles: string[] = [] + if (width) + styles.push(`width: ${parseRect(width)}`) + if (height) + styles.push(`height: ${parseRect(height)}`) + token.attrSet('style', styles.join(';')) + } + const text = new Token('text', '', 0) + text.content = filename + token.children = [text] + } + // 渲染为音频 + else if (EXTENSION_AUDIOS.includes(extname)) { + const token = state.push('audio_open', 'audio', 1) + token.content = filename + token.attrSet('controls', 'true') + token.attrSet('preload', 'metadata') + token.attrSet('aria-label', filename) + const sourceToken = state.push('source_open', 'source', 1) + sourceToken.attrSet('src', resolveFilenameToAssetPath(filename)) + state.push('audio_close', 'audio', -1) + } + // 渲染为视频,使用 ArtPlayer + else if (EXTENSION_VIDEOS.includes(extname)) { + const token = state.push('video_artPlayer_open', 'ArtPlayer', 1) + const type = extname.slice(1) + checkSupportType(type) + token.attrSet('src', resolveFilenameToAssetPath(filename)) + token.attrSet('type', type) + token.attrSet('width', '100%') + token.attrSet(':fullscreen', 'true') + token.attrSet(':flip', 'true') + token.attrSet(':playback-rate', 'true') + token.attrSet(':aspect-ratio', 'true') + token.attrSet(':setting', 'true') + token.attrSet(':pip', 'true') + token.attrSet(':volume', '0.75') + token.content = filename + state.push('video_artPlayer_close', 'ArtPlayer', -1) + } + // 渲染为 pdf + else if (extname === '.pdf') { + const token = state.push('pdf_open', 'PDFViewer', 1) + token.attrSet('src', resolveFilenameToAssetPath(filename)) + token.attrSet('width', '100%') + for (const hash of hashes) { + const [key, value] = hash.split('=').map(x => x.trim()) + token.attrSet(key, value) + } + token.content = filename + state.push('pdf_close', 'PDFViewer', -1) + } + // 非受支持的外部资源,渲染为链接 + else if (isLinkHttp(filename) || (extname && extname !== '.md')) { + const token = state.push('link_open', 'a', 1) + token.attrSet('href', filename) + token.attrSet('target', '_blank') + token.attrSet('rel', 'noopener noreferrer') + token.content = filename + const content = state.push('text', '', 0) + content.content = filename + state.push('link_close', 'a', -1) + } + // 剩余情况,如内部的 markdown 文件 + // 在 obsidian_embed_link renderer rule 中处理 + else { + const token = state.push('obsidian_embed_link', '', 0) + token.markup = '![[]]' + token.meta = { + filename: filename.trim(), + hashes: hashes.map(hash => hash.trim()), + settings: settings?.trim(), + } as EmbedLinkMeta + token.content = content + } + + state.line = startLine + 1 + return true +} + +export function embedLinkPlugin(md: Markdown, app: App): void { + md.block.ruler.before( + 'import_code', + 'obsidian_embed_link', + embedLinkDef, + { alt: ['paragraph', 'reference', 'blockquote', 'list'] }, + ) + md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => { + const token = tokens[idx] + const { filename, hashes, settings } = token.meta as EmbedLinkMeta + const internalPage = findFirstPage(app, filename, env.filePathRelative ?? '') + // 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面 + if (internalPage) { + const { content: rawContent } = grayMatter(internalPage.content) + const content = extractContentByHeadings(rawContent, hashes) + internalPage.filePathRelative && (env.importedFiles ??= []).push(internalPage.filePathRelative) + return md.render(content, cleanMarkdownEnv(internalPage.markdownEnv)) + } + + // 其他资源,解析为链接 + const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename) + const anchor = hashes.at(-1) + const slug = anchor ? `#${slugify(anchor)}` : '' + const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : '')) + return `${ + md.utils.escapeHtml(text) + }` + } +} + +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 = {} + + content = content.replaceAll(/(?:{3,})[\s\S]*?\k/g, (matched) => { + const key = hash(matched) + containers[key] = matched + return `` + }) + 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(//g, (_, key) => containers[key] ?? '') +} diff --git a/plugins/plugin-md-power/src/node/obsidian/index.ts b/plugins/plugin-md-power/src/node/obsidian/index.ts new file mode 100644 index 00000000..9bc3e42a --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/index.ts @@ -0,0 +1,27 @@ +import type { App } from 'vuepress' +import type { Markdown } from 'vuepress/markdown' +import type { MarkdownPowerPluginOptions } from '../../shared/index.js' +import { isPlainObject } from 'vuepress/shared' +import { commentPlugin } from './comment.js' +import { embedLinkPlugin } from './embedLink.js' +import { wikiLinkPlugin } from './wikiLink.js' + +export function obsidianPlugin( + md: Markdown, + app: App, + options: MarkdownPowerPluginOptions, +) { + if (options.obsidian === false) + return + + const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {} + + if (obsidian.wikiLink !== false) + wikiLinkPlugin(md, app) + + if (obsidian.embedLink !== false) + embedLinkPlugin(md, app) + + if (obsidian.comment !== false) + commentPlugin(md) +} diff --git a/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts b/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts new file mode 100644 index 00000000..f6ea7ef4 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts @@ -0,0 +1,155 @@ +/** + * Wiki Link 是属于 obsidian 官方扩展的 markdown 语法 + * + * [[文件名]] [[文件名#标题]] [[文件名#标题#标题]] [[文件名#标题|别名]] + * + * @see - https://obsidian.md/zh/help/links + * + * 插件提供的是对该语法的兼容性支持,并非实现其完全的功能。 + */ + +import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs' +import type { App } from 'vuepress' +import type { Markdown, MarkdownEnv } from 'vuepress/markdown' +import { sortBy } from '@pengzhanbo/utils' +import { ensureLeadingSlash, isLinkHttp, removeLeadingSlash } from 'vuepress/shared' +import { path } from 'vuepress/utils' +import { resolvePaths } from '../enhance/links.js' +import { slugify } from '../utils/slugify.js' + +interface WikiLinkMeta { + filename: string + alias: string + titles: string[] +} + +const wikiLinkDef: RuleInline = (state, silent) => { + let found = false + const max = state.posMax + const start = state.pos + + if ( + state.src.charCodeAt(start) !== 0x5B + || state.src.charCodeAt(start + 1) !== 0x5B + ) { + return false + } + + /* istanbul ignore if -- @preserve */ + if (silent) + return false + + // - [[]] + if (max - start < 5) + return false + + state.pos = start + 2 + + // 查找 ]] + while (state.pos < max) { + if (state.src.charCodeAt(state.pos) === 0x5D + && state.src.charCodeAt(state.pos + 1) === 0x5D) { + found = true + break + } + + state.md.inline.skipToken(state) + } + + if (!found || start + 2 === state.pos) { + state.pos = start + return false + } + // [[xxxx]] + // ^^^^ <- content + const content = state.src.slice(start + 2, state.pos).trim() + // found! + state.posMax = state.pos + state.pos = start + 2 + + const [file, alias] = content.split('|') + const [filename, ...titles] = file.trim().split('#') + + const token = state.push('obsidian_wiki_link', '', 0) + token.markup = '[[]]' + token.meta = { + filename: filename.trim(), + titles: titles.map(title => title.trim()), + alias: alias?.trim(), + } as WikiLinkMeta + token.content = content + + state.pos = state.posMax + 2 + state.posMax = max + + return true +} + +export function wikiLinkPlugin(md: Markdown, app: App) { + md.inline.ruler.before('emphasis', 'obsidian_wiki_link', wikiLinkDef) + md.renderer.rules.obsidian_wiki_link = (tokens, idx, _, env: MarkdownEnv) => { + const token = tokens[idx] + const { filename, titles, alias } = token.meta as WikiLinkMeta + const anchor = titles.at(-1) + const slug = anchor ? `#${slugify(anchor)}` : '' + // external link + if (isLinkHttp(filename)) { + const text = alias || (filename + (titles.length ? ` > ${titles.join(' > ')}` : '')) + return `${ + md.utils.escapeHtml(text) + }` + } + // internal hash link + if (!filename) { // internal page hash link + return `${md.utils.escapeHtml(alias) || (titles.length ? `` : '')}` + } + const internal = findFirstPage(app, filename, env.filePathRelative ?? '') + if (internal) { + const { absolutePath, relativePath } = resolvePaths( + internal.filePathRelative!, + env.base || '/', + env.filePathRelative ?? null, + ) + ;(env.links ??= []).push({ + raw: internal.filePathRelative!, + absolute: absolutePath, + relative: relativePath, + }) + return `${md.utils.escapeHtml(alias) || (titles.length ? `` : '')}` + } + + // 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 `${ + md.utils.escapeHtml(text) + }` + } +} + +export function findFirstPage(app: App, filename: string, relativePath: string) { + const dirname = path.dirname(relativePath) + const withExt = path.extname(filename) ? filename : `${filename}.md` + const sorted = sortBy(app.pages ?? [], page => page.filePathRelative?.split('/').length ?? Infinity) + return sorted.find((page) => { + const title = page.title || page.frontmatter?.title || page.data.title + // 匹配标题, 优先从最短路径开始匹配 + if (title === filename) + return true + + const relative = page.filePathRelative + /* istanbul ignore if -- @preserve */ + if (!relative) + return false + + const filepath = filename[0] === '.' ? path.join(dirname, filename) : removeLeadingSlash(filename) + + // 精确匹配 + if ((filepath.slice(-1) === '/' && (relative === `${filepath}README.md` || relative === `${filepath}index.html`)) || relative === withExt) { + return true + } + + // 模糊匹配,优先从最短路径匹配,sorted 已按照路径长度排序 + return (filepath.slice(-1) === '/' && (relative.endsWith(`${filepath}README.md`) || relative.endsWith(`${filepath}index.html`))) || relative.endsWith(withExt) + }) +} diff --git a/plugins/plugin-md-power/src/node/plugin.ts b/plugins/plugin-md-power/src/node/plugin.ts index 251c2dc0..bd8d2ae5 100644 --- a/plugins/plugin-md-power/src/node/plugin.ts +++ b/plugins/plugin-md-power/src/node/plugin.ts @@ -13,6 +13,7 @@ import { linksPlugin } from './enhance/links.js' import { iconPlugin } from './icon/index.js' import { inlineSyntaxPlugin } from './inline/index.js' import { LOCALE_OPTIONS } from './locales/index.js' +import { obsidianPlugin } from './obsidian/index.js' import { prepareConfigFile } from './prepareConfigFile.js' import { provideData } from './provideData.js' @@ -105,6 +106,7 @@ export function markdownPowerPlugin( embedSyntaxPlugin(md, options) inlineSyntaxPlugin(md, options) iconPlugin(md, options.icon ?? (isPlainObject(options.icons) ? options.icons : {})) + obsidianPlugin(md, app, options) if (options.demo) demoPlugin(app, md) diff --git a/plugins/plugin-md-power/src/node/utils/slugify.ts b/plugins/plugin-md-power/src/node/utils/slugify.ts new file mode 100644 index 00000000..f50f2140 --- /dev/null +++ b/plugins/plugin-md-power/src/node/utils/slugify.ts @@ -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() +} diff --git a/plugins/plugin-md-power/src/shared/obsidian.ts b/plugins/plugin-md-power/src/shared/obsidian.ts new file mode 100644 index 00000000..9b6114ef --- /dev/null +++ b/plugins/plugin-md-power/src/shared/obsidian.ts @@ -0,0 +1,5 @@ +export interface ObsidianOptions { + wikiLink?: boolean + embedLink?: boolean + comment?: boolean +} diff --git a/plugins/plugin-md-power/src/shared/pdf.ts b/plugins/plugin-md-power/src/shared/pdf.ts index 5e13ea5b..290d87b0 100644 --- a/plugins/plugin-md-power/src/shared/pdf.ts +++ b/plugins/plugin-md-power/src/shared/pdf.ts @@ -18,7 +18,7 @@ export interface PDFTokenMeta extends SizeOptions { * * 要显示的页码 */ - page?: number + page?: number | string /** * Whether to hide toolbar * diff --git a/plugins/plugin-md-power/src/shared/plugin.ts b/plugins/plugin-md-power/src/shared/plugin.ts index 91b008a4..c6f23115 100644 --- a/plugins/plugin-md-power/src/shared/plugin.ts +++ b/plugins/plugin-md-power/src/shared/plugin.ts @@ -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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3501bfce..dca395ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,9 @@ importers: esbuild: specifier: ^0.28.0 version: 0.28.0 + gray-matter: + specifier: catalog:prod + version: 4.0.3 image-size: specifier: catalog:prod version: 2.0.2 diff --git a/theme/src/client/components/VPLink.vue b/theme/src/client/components/VPLink.vue index 048997cf..31ba1926 100644 --- a/theme/src/client/components/VPLink.vue +++ b/theme/src/client/components/VPLink.vue @@ -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)" > - - {{ text || href }} - + {{ resolvedText || href }} + {{ theme.openNewWindowText || '(Open in new window)' }} diff --git a/theme/src/client/composables/link.ts b/theme/src/client/composables/link.ts index 0f83a9b2..099ad0a7 100644 --- a/theme/src/client/composables/link.ts +++ b/theme/src/client/composables/link.ts @@ -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 diff --git a/theme/src/client/features/components/PageContextMenu.vue b/theme/src/client/features/components/PageContextMenu.vue index e13797c8..0b5c3874 100644 --- a/theme/src/client/features/components/PageContextMenu.vue +++ b/theme/src/client/features/components/PageContextMenu.vue @@ -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; diff --git a/theme/src/node/detector/fields.ts b/theme/src/node/detector/fields.ts index 42631a24..265d44d4 100644 --- a/theme/src/node/detector/fields.ts +++ b/theme/src/node/detector/fields.ts @@ -67,6 +67,7 @@ export const MARKDOWN_POWER_FIELDS: (keyof MarkdownPowerPluginOptions)[] = [ 'youtube', 'qrcode', 'encrypt', + 'obsidian', 'locales', ] diff --git a/tsconfig.json b/tsconfig.json index 3d570563..69082f9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "jsx": "preserve", - "baseUrl": ".", "paths": { "@internal/*": ["./docs/.vuepress/.temp/internal/*"], "@theme/*": ["./theme/src/client/components/*"]