diff --git a/docs/en/guide/markdown/obsidian.md b/docs/en/guide/markdown/obsidian.md index d321c219..4ac5bbcd 100644 --- a/docs/en/guide/markdown/obsidian.md +++ b/docs/en/guide/markdown/obsidian.md @@ -7,8 +7,7 @@ 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. +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: @@ -21,7 +20,7 @@ Currently supported Obsidian extension syntax includes: ## Wiki Links -Wiki Links are syntax for linking to other notes in Obsidian. +Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links. ### Syntax @@ -31,6 +30,7 @@ Wiki Links are syntax for linking to other notes in Obsidian. [[filename#heading#subheading]] [[filename|alias]] [[filename#heading|alias]] +[[https://example.com|External Link]] ``` ### Filename Search Rules @@ -39,15 +39,14 @@ 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 +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, with shortest path taking precedence -- **Directory form** (ending with `/`): Matches `README.md` or `index.html` within that 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:** @@ -55,22 +54,21 @@ Assuming the following document structure: ```txt docs/ -├── README.md (title: "Home") +├── README.md ├── guide/ -│ ├── README.md (title: "Guide") +│ ├── README.md │ └── 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) | +| 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 @@ -86,31 +84,26 @@ In `docs/guide/markdown/obsidian.md`: [[https://example.com|External Link]] ---- - **Internal Anchor Links:** **Input:** ```md -[[QR Code]] [[npm-to]] [[guide/markdown/math]] [[#Wiki Links]] -[[file-tree#configuration]] +[[file-tree#Configuration]] ``` **Output:** -[[QR Code]] - [[npm-to]] [[guide/markdown/math]] [[#Wiki Links]] -[[file-tree#configuration]] +[[file-tree#Configuration]] [Obsidian Official - **Wiki Links**](https://obsidian.md/en/help/links){.readmore} @@ -136,22 +129,38 @@ Filename search rules are the same as [Wiki Links](#filename-search-rules). **Syntax:** ```md -![[image.png]] -![[image.png|300]] -![[image.png|300x200]] +![[image]] +![[image|300]] +![[image|300x200]] ``` Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm` -**Input:** +**Example:** + +::: demo markdown title="Basic Image" expanded ```md ![[images/custom-hero.jpg]] ``` -**Output:** +::: -![[images/custom-hero.jpg]] +::: demo markdown title="Set Width" expanded + +```md +![[images/custom-hero.jpg|300]] +``` + +::: + +::: demo markdown title="Set Width and Height" expanded + +```md +![[images/custom-hero.jpg|300x200]] +``` + +::: ### PDF Embeds @@ -162,46 +171,38 @@ Supported formats: `jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `i ```md ![[document.pdf]] -![[document.pdf#page=1]] -![[document.pdf#page=1#height=300]] +![[document.pdf#page=1]] +![[document.pdf#page=1#height=300]] ``` +Supported formats: `pdf` + --- ### Audio Embeds -> [!note] -> Audio embeds require the file path to be correct and the file to exist in the document directory. - -**Input:** +**Syntax:** ```md -![[audio.mp3]] +![[audio file]] ``` -**Output:** - -![[https://publish-01.obsidian.md/access/cf01a21839823cd6cbe18031acf708c0/Attachments/audio/Excerpt%20from%20Mother%20of%20All%20Demos%20(1968).ogg]] - Supported formats: `mp3`, `flac`, `wav`, `ogg`, `opus`, `webm`, `acc` --- ### Video Embeds -> [!note] +> [!NOTE] > Video embeds require the `markdown.artPlayer` plugin to be enabled for proper functionality. -**Input:** +**Syntax:** ```md -![[video.mp4]] +![[video file]] +![[video file#height=400]] ``` -**Output:** - -![[https://artplayer.org/assets/sample/video.mp4]] - Supported formats: `mp4`, `webm`, `mov`, etc. --- @@ -214,12 +215,12 @@ Content fragments under a specified heading can be embedded using `#heading`: ```md ![[my-note]] -![[my-note#heading-one]] -![[my-note#heading-one#subheading]] +![[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} +[Obsidian Official - **Insert Files**](https://obsidian.md/en/help/embeds){.readmore} +[Obsidian Official - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore} ## Comments @@ -280,28 +281,25 @@ Content before the comment %% This is a block comment. - -It can span multiple lines. %% -Content after the comment +It can span multiple lines. -> Related Documentation: [Obsidian Official - Comments](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B) +[Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore} ## Configuration -You can enable or disable these plugins in the theme configuration: +Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration: ```ts title=".vuepress/config.ts" export default defineUserConfig({ theme: plumeTheme({ plugins: { mdPower: { - // Obsidian compatibility plugin configuration obsidian: { wikiLink: true, // Wiki Links - embedLink: true, // Embeds - comment: true, // Comments + embedLink: true, // Embeds + comment: true, // Comments }, pdf: true, // PDF embed functionality artPlayer: true, // Video embed functionality @@ -316,15 +314,15 @@ export default defineUserConfig({ :::: field-group ::: field name="wikiLink" type="boolean" default="true" optional -Enable Wiki Links syntax +Enable Wiki Links syntax. ::: ::: field name="embedLink" type="boolean" default="true" optional -Enable embed content syntax +Enable embed content syntax. ::: ::: field name="comment" type="boolean" default="true" optional -Enable comment syntax +Enable comment syntax. ::: :::: @@ -332,7 +330,8 @@ 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 +- 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 `pdf` plugin to be enabled simultaneously -- Video embeds require the `artPlayer` plugin to be enabled simultaneously +- 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 diff --git a/docs/guide/markdown/obsidian.md b/docs/guide/markdown/obsidian.md index 3033a34e..828ed060 100644 --- a/docs/guide/markdown/obsidian.md +++ b/docs/guide/markdown/obsidian.md @@ -15,12 +15,12 @@ permalink: /guide/markdown/obsidian/ - [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面 - [注释](#注释) - 添加仅在编辑时可见的注释 -::: warning 不计划对 obsidian 社区的第三方插件提供的扩展语法进行支持 +::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法 ::: ## Wiki 链接 -Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。 +Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。 ### 语法 @@ -30,6 +30,7 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。 [[文件名#标题#子标题]] [[文件名|别名]] [[文件名#标题|别名]] +[[https://example.com|外部链接]] ``` ### 文件名搜索规则 @@ -38,15 +39,14 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。 **匹配优先级:** -1. **页面标题** - 优先匹配页面的标题 -2. **完整路径** - 精确匹配文件路径 -3. **模糊匹配** - 匹配路径结尾的文件名 +1. **完整路径** - 精确匹配文件路径 +2. **模糊匹配** - 匹配路径结尾的文件名,优先匹配最短路径 **路径解析规则:** - **相对路径**(以 `.` 开头):相对于当前文件所在目录解析 - **绝对路径**(不以 `.` 开头):在整个文档树中搜索,优先匹配最短路径 -- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md` 或 `index.html` +- **目录形式**(以 `/` 结尾):匹配该目录下的 `README.md` **示例:** @@ -54,22 +54,21 @@ Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。 ```txt docs/ -├── README.md (title: "首页") +├── README.md ├── guide/ -│ ├── README.md (title: "指南") +│ ├── README.md │ └── 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`(目录形式) | +| 语法 | 匹配结果 | +| -------------- | -------------------------------------------------------- | +| `[[obsidian]]` | 匹配 `docs/guide/markdown/obsidian.md`(通过文件名检索) | +| `[[./]]` | 匹配 `docs/guide/markdown/README.md`(相对路径) | +| `[[../]]` | 匹配 `docs/guide/README.md`(上级目录) | +| `[[guide/]]` | 匹配 `docs/guide/README.md`(目录形式) | ### 示例 @@ -90,7 +89,6 @@ docs/ **输入:** ```md -[[二维码]] [[npm-to]] [[guide/markdown/math]] [[#Wiki 链接]] @@ -99,8 +97,6 @@ docs/ **输出:** -[[二维码]] - [[npm-to]] [[guide/markdown/math]] @@ -133,22 +129,38 @@ docs/ **语法:** ```md -![[image.png]] -![[image.png|300]] -![[image.png|300x200]] +![[图片]] +![[图片|宽度]] +![[图片|宽度x高度]] ``` -支持格式:`jpg`, `jpeg`, `png`, `gif`, `avif`, `webp`, `svg`, `bmp`, `ico`, `tiff`, `apng`, `jfif`, `pjpeg`, `pjp`, `xbm` +支持格式:`jpg`、`jpeg`、`png`、`gif`、`avif`、`webp`、`svg`、`bmp`、`ico`、`tiff`、`apng`、`jfif`、`pjpeg`、`pjp`、`xbm` -**输入:** +**示例:** + +::: demo markdown title="基础图片" expanded ```md ![[images/custom-hero.jpg]] ``` -**输出:** +::: -![[images/custom-hero.jpg]] +::: demo markdown title="设置宽度" expanded + +```md +![[images/custom-hero.jpg|300]] +``` + +::: + +::: demo markdown title="设置宽度和高度" expanded + +```md +![[images/custom-hero.jpg|300x200]] +``` + +::: ### PDF 嵌入 @@ -158,48 +170,40 @@ docs/ **语法:** ```md -![[document.pdf]] -![[document.pdf#page=1]] -![[document.pdf#page=1#height=300]] +![[文档.pdf]] +![[文档.pdf#page=1]] +![[文档.pdf#page=1#height=300]] ``` +支持格式:`pdf` + --- ### 音频嵌入 -> [!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` +支持格式:`mp3`、`flac`、`wav`、`ogg`、`opus`、`webm`、`acc` --- ### 视频嵌入 -> [!note] +> [!NOTE] > 视频嵌入需要启用 `markdown.artPlayer` 插件才能正常工作。 -**输入:** +**语法:** ```md -![[video.mp4]] +![[视频文件]] +![[视频文件#height=400]] ``` -**输出:** - -![[https://artplayer.org/assets/sample/video.mp4]] - -支持格式:`mp4`, `webm`, `mov` 等 +支持格式:`mp4`、`webm`、`mov` 等 --- @@ -277,28 +281,25 @@ docs/ %% 这是一个块级注释。 - -可以跨越多行。 %% -注释之后的内容 +可以跨越多行。 [Obsidian 官方 - 注释](https://obsidian.md/zh/help/syntax#%E6%B3%A8%E9%87%8A){.readmore} ## 配置 -你可以在主题配置中启用或禁用这些插件: +Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用: ```ts title=".vuepress/config.ts" export default defineUserConfig({ theme: plumeTheme({ plugins: { mdPower: { - // Obsidian 兼容插件配置 obsidian: { wikiLink: true, // Wiki 链接 - embedLink: true, // 嵌入内容 - comment: true, // 注释 + embedLink: true, // 嵌入内容 + comment: true, // 注释 }, pdf: true, // PDF 嵌入功能 artPlayer: true, // 视频嵌入功能 @@ -313,15 +314,15 @@ export default defineUserConfig({ :::: field-group ::: field name="wikiLink" type="boolean" default="true" optional -启用 Wiki 链接语法 +启用 Wiki 链接语法。 ::: ::: field name="embedLink" type="boolean" default="true" optional -启用嵌入内容语法 +启用嵌入内容语法。 ::: ::: field name="comment" type="boolean" default="true" optional -启用注释语法 +启用注释语法。 ::: :::: @@ -331,5 +332,6 @@ export default defineUserConfig({ - 这些插件提供的是 **兼容性支持**,并非完全实现 Obsidian 的全部功能 - 部分 Obsidian 特有的功能(如内部链接的图谱视图、双向链接等)不在支持范围内 - 嵌入内容时,被嵌入的页面也会参与主题的构建过程 -- PDF 嵌入需要同时启用 `pdf` 插件 -- 视频嵌入需要同时启用 `artPlayer` 插件 +- PDF 嵌入需要同时启用 `markdown.pdf` 插件 +- 视频嵌入需要同时启用 `markdown.artPlayer` 插件 +- 以 `/` 开头或使用 `./` 形式的嵌入资源会从 `public` 目录加载 diff --git a/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts b/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts new file mode 100644 index 00000000..a63de2a2 --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts @@ -0,0 +1,421 @@ +import type { App } from 'vuepress' +import type { MarkdownEnv } from 'vuepress/markdown' +import MarkdownIt from 'markdown-it' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js' +import { initPagePaths } from '../src/node/obsidian/findFirstPage.js' + +const mockGlobSync = vi.fn() +const mockReadFileSync = vi.fn() + +vi.mock('vuepress/utils', () => ({ + tinyglobby: { + globSync: (...args: unknown[]) => mockGlobSync(...args), + }, + fs: { + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + }, + path: { + dirname: vi.fn((p: string) => p.split('/').slice(0, -1).join('/') || '.'), + extname: vi.fn((p: string) => { + const i = p.lastIndexOf('.') + return i > 0 ? p.slice(i) : '' + }), + join: vi.fn((...args: string[]) => args.join('/')), + }, + hash: vi.fn((s: string) => `hash_${s.length}`), +})) + +vi.mock('gray-matter', () => ({ + default: vi.fn((content: string) => ({ + content: content.replace(/^---[\s\S]*?---\n?/, ''), + data: {}, + })), +})) + +vi.mock('@vuepress/helper', () => ({ + removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')), +})) + +function createMockApp(pages: App['pages'] = []): App { + return { + pages, + options: { + pagePatterns: ['**/*.md'], + }, + dir: { + source: () => '/source', + }, + } as unknown as App +} + +function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { + return { + filePathRelative, + base: '/', + links: [], + importedFiles: [], + } +} + +function createMarkdownWithMockRules() { + return MarkdownIt({ html: true }).use((md) => { + md.block.ruler.before('code', 'import_code', () => false) + md.renderer.rules.import_code = () => '' + }) +} + +describe('embedLinkPlugin', () => { + beforeEach(() => { + mockGlobSync.mockReset() + mockReadFileSync.mockReset() + }) + + // ==================== Asset Embedding ==================== + + describe('asset embedding', () => { + beforeEach(() => { + mockGlobSync.mockReturnValue([]) + }) + + it('should render image embed', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[image.png]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[image.png|300]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[image.png|300x200]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[audio.mp3]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[video.mp4]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[document.pdf]]') + expect(result).toContain(' { + 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(' { + 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(' { + 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(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[nonexistent#section]]') + expect(result).toContain(' { + const contentWithContainers = `--- +title: Test +--- + +# Section + +::: info +This is a container +::: + +Regular content. +` + + beforeEach(() => { + mockGlobSync.mockReturnValue(['test.md']) + mockReadFileSync.mockReturnValue(contentWithContainers) + const app = createMockApp() + initPagePaths(app) + }) + + it('should preserve container syntax when embedding', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('![[test#Section]]', env) + + expect(result).toContain('::: info') + expect(result).toContain('This is a container') + expect(result).toContain('Regular content') + }) + }) + + // ==================== Error Handling ==================== + + describe('error handling', () => { + it('should return empty string when file read fails', () => { + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT') + }) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, app) + const env = createMockEnv() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = md.render('![[guide]]', env) + + expect(result).toBe('') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('can not read file')) + warnSpy.mockRestore() + }) + }) + + // ==================== Heading Search Edge Cases ==================== + + describe('heading search edge cases', () => { + it('should find heading when same text appears at different nesting levels', () => { + const content = `# Title + +## Summary + +Summary content. + +## Details + +### Summary + +Nested summary under details. + +## Conclusion + +Conclusion content.` + + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockReturnValue(content) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + // Should match first "Summary" at level 2 + const result = md.render('![[guide#Summary]]', env) + expect(result).toContain('Summary content.') + expect(result).not.toContain('Nested summary') + }) + + it('should return empty string when heading not found', () => { + const content = `# Title + +## Section + +Content.` + + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockReturnValue(content) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = md.render('![[guide#Nonexistent]]', env) + + expect(result).toBe('') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No heading found')) + warnSpy.mockRestore() + }) + }) + + // ==================== Edge Cases ==================== + + describe('edge cases', () => { + beforeEach(() => { + mockGlobSync.mockReturnValue([]) + }) + + it('should not parse embed not ending with ]]', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[image.png]') + expect(result).toContain('![[image.png]') + }) + + it('should not parse empty embed link', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[]]') + expect(result).toContain('![[]]') + }) + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts b/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts deleted file mode 100644 index 54e50377..00000000 --- a/plugins/plugin-md-power/__test__/obsidianEmbedLinkComplex.spec.ts +++ /dev/null @@ -1,446 +0,0 @@ -import type { App } from 'vuepress' -import type { MarkdownEnv } from 'vuepress/markdown' -import MarkdownIt from 'markdown-it' -import { describe, expect, it, vi } from 'vitest' -import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js' - -function createMockApp(pages: App['pages'] = []): App { - return { - pages, - } as App -} - -function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { - return { - filePathRelative, - base: '/', - links: [], - importedFiles: [], - } -} - -function createMarkdownWithMockRules() { - return MarkdownIt({ html: true }).use((md) => { - md.block.ruler.before('code', 'import_code', () => false) - md.renderer.rules.import_code = () => '' - }) -} - -vi.mock('gray-matter', () => ({ - default: vi.fn(content => ({ - content: content.replace(/^---[\s\S]*?---\n?/, ''), - data: {}, - })), -})) - -describe('embedLinkPlugin - internal markdown embed', () => { - it('should embed entire markdown file when no heading specified', async () => { - const mockPage = { - path: '/docs/guide/', - filePathRelative: 'docs/guide.md', - title: 'Guide', - content: `--- -title: Guide ---- - -# Introduction - -This is the guide content. - -## Getting Started - -Step 1, Step 2, Step 3. -`, - markdownEnv: { base: '/' }, - } - - const mockApp = createMockApp([mockPage] as unknown as App['pages']) - const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) - - const result = md.render('![[Guide]]', createMockEnv('test.md')) - expect(result).toContain(' { - 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 deleted file mode 100644 index 1dd7a227..00000000 --- a/plugins/plugin-md-power/__test__/obsidianEmbedLinkPlugin.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { App } from 'vuepress' -import type { MarkdownEnv } from 'vuepress/markdown' -import MarkdownIt from 'markdown-it' -import { describe, expect, it } from 'vitest' -import { embedLinkPlugin } from '../src/node/obsidian/embedLink.js' - -function createMockApp(pages: App['pages'] = []): App { - return { - pages, - } as App -} - -function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { - return { - filePathRelative, - base: '/', - links: [], - importedFiles: [], - } -} - -function createMarkdownWithMockRules() { - return MarkdownIt({ html: true }).use((md) => { - md.block.ruler.before('code', 'import_code', () => false) - md.renderer.rules.import_code = () => '' - }) -} - -describe('embedLinkPlugin', () => { - const mockApp = createMockApp() - const md = createMarkdownWithMockRules().use(embedLinkPlugin, mockApp) - - it('should render image embed', () => { - const result = md.render('![[image.png]]') - expect(result).toContain(' { - 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__/obsidianExtractContent.spec.ts b/plugins/plugin-md-power/__test__/obsidianExtractContent.spec.ts new file mode 100644 index 00000000..f2ebc20f --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianExtractContent.spec.ts @@ -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 = {} + + content = content.replaceAll(/(?:{3,})[\s\S]*?\k/g, (matched) => { + const key = `CONTAINER_${Object.keys(containers).length}` + 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) { + 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] ?? '') +} + +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.') + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianFindFirstPage.spec.ts b/plugins/plugin-md-power/__test__/obsidianFindFirstPage.spec.ts new file mode 100644 index 00000000..dd22eeeb --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianFindFirstPage.spec.ts @@ -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() + }) + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts b/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts index 2b754e0a..78daa1ee 100644 --- a/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts +++ b/plugins/plugin-md-power/__test__/obsidianPlugin.spec.ts @@ -1,12 +1,28 @@ import type { App } from 'vuepress' import MarkdownIt from 'markdown-it' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { obsidianPlugin } from '../src/node/obsidian/index.js' +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, - } as App + options: { + pagePatterns: ['**/*.md'], + }, + dir: { + source: () => '/source', + }, + } as unknown as App } function createMarkdownWithMockRules() { @@ -20,13 +36,11 @@ 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(' { it('should allow disabling specific plugins', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(md, mockApp, { obsidian: { wikiLink: false } }) + obsidianPlugin(mockApp, md, { obsidian: { wikiLink: false } }) const wikiResult = md.render('[[Page]]') expect(wikiResult).not.toContain(' { it('should disable all plugins when obsidian is false', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(md, mockApp, { obsidian: false }) + obsidianPlugin(mockApp, md, { obsidian: false }) const result = md.render('![[image.png]]') - expect(result).not.toContain(' { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(md, mockApp, { obsidian: { embedLink: false } }) + obsidianPlugin(mockApp, md, { obsidian: { embedLink: false } }) const result = md.render('![[image.png]]') expect(result).not.toContain(' { it('should disable comment when explicitly set to false', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(md, mockApp, { obsidian: { comment: false } }) - - const embedResult = md.render('![[image.png]]') - expect(embedResult).toContain(' ({ + 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(' { + 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') + }) + }) + + // ==================== 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(' { + 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') + }) + }) + + // ==================== 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(' { + const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin) + const env = createMockEnv() + + const result = md.render('[[guide#Getting Started]]', env) + + expect(result).toContain(' { + 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(' { + 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(' { + 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(' { + 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(' { + 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<') + }) + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts b/plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts deleted file mode 100644 index 0ece210c..00000000 --- a/plugins/plugin-md-power/__test__/obsidianWikiLinkPlugin.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { App } from 'vuepress' -import type { MarkdownEnv } from 'vuepress/markdown' -import MarkdownIt from 'markdown-it' -import { describe, expect, it } from 'vitest' -import { findFirstPage, wikiLinkPlugin } from '../src/node/obsidian/wikiLink.js' - -function createMockApp(pages: App['pages'] = []): App { - return { - pages, - } as App -} - -function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { - return { - filePathRelative, - base: '/', - links: [], - importedFiles: [], - } -} - -describe('wikiLinkPlugin', () => { - const mockApp = createMockApp([ - { - path: '/docs/getting-started/', - filePathRelative: 'docs/getting-started/README.md', - title: 'Getting Started', - }, - { - path: '/docs/guide/intro/', - filePathRelative: 'docs/guide/intro.md', - title: 'Introduction', - }, - { - path: '/api/utils/', - filePathRelative: 'api/utils.md', - title: 'Utils', - }, - ] as App['pages']) - - const md = new MarkdownIt({ html: true }).use(wikiLinkPlugin, mockApp) - - it('should render internal wiki link to existing page', () => { - const env = createMockEnv('docs/page.md') - const result = md.render('[[Getting Started]]', env) - expect(result).toContain(' { - 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/src/node/obsidian/embedLink.ts b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts index ac551218..200f8497 100644 --- a/plugins/plugin-md-power/src/node/obsidian/embedLink.ts +++ b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts @@ -17,15 +17,16 @@ import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' import type { App } from 'vuepress' import type { Markdown, MarkdownEnv } from 'vuepress/markdown' +import { attempt } from '@pengzhanbo/utils' import grayMatter from 'gray-matter' import Token from 'markdown-it/lib/token.mjs' import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared' -import { hash, path } from 'vuepress/utils' +import { fs, hash, path } from 'vuepress/utils' import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js' import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js' import { parseRect } from '../utils/parseRect.js' import { slugify } from '../utils/slugify.js' -import { findFirstPage } from './wikiLink.js' +import { findFirstPage } from './findFirstPage.js' interface EmbedLinkMeta { filename: string @@ -173,13 +174,22 @@ export function embedLinkPlugin(md: Markdown, app: App): void { 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 ?? '') + const pagePath = findFirstPage(filename, env.filePathRelative ?? '') // 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面 - if (internalPage) { - const { content: rawContent } = grayMatter(internalPage.content) + 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) - internalPage.filePathRelative && (env.importedFiles ??= []).push(internalPage.filePathRelative) - return md.render(content, cleanMarkdownEnv(internalPage.markdownEnv)) + pagePath && (env.importedFiles ??= []).push(pagePath) + return md.render(content, cleanMarkdownEnv(env)) } // 其他资源,解析为链接 diff --git a/plugins/plugin-md-power/src/node/obsidian/findFirstPage.ts b/plugins/plugin-md-power/src/node/obsidian/findFirstPage.ts new file mode 100644 index 00000000..84ccdd60 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/findFirstPage.ts @@ -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 + }) +} diff --git a/plugins/plugin-md-power/src/node/obsidian/index.ts b/plugins/plugin-md-power/src/node/obsidian/index.ts index 9bc3e42a..843475f1 100644 --- a/plugins/plugin-md-power/src/node/obsidian/index.ts +++ b/plugins/plugin-md-power/src/node/obsidian/index.ts @@ -4,11 +4,14 @@ import type { MarkdownPowerPluginOptions } from '../../shared/index.js' import { isPlainObject } from 'vuepress/shared' import { commentPlugin } from './comment.js' import { embedLinkPlugin } from './embedLink.js' +import { initPagePaths } from './findFirstPage.js' import { wikiLinkPlugin } from './wikiLink.js' +export * from './findFirstPage.js' + export function obsidianPlugin( - md: Markdown, app: App, + md: Markdown, options: MarkdownPowerPluginOptions, ) { if (options.obsidian === false) @@ -16,8 +19,10 @@ export function obsidianPlugin( const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {} + initPagePaths(app) + if (obsidian.wikiLink !== false) - wikiLinkPlugin(md, app) + wikiLinkPlugin(md) if (obsidian.embedLink !== false) embedLinkPlugin(md, app) diff --git a/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts b/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts index f6ea7ef4..5cc94681 100644 --- a/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts +++ b/plugins/plugin-md-power/src/node/obsidian/wikiLink.ts @@ -9,13 +9,12 @@ */ 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 { 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 @@ -85,7 +84,7 @@ const wikiLinkDef: RuleInline = (state, silent) => { return true } -export function wikiLinkPlugin(md: Markdown, app: App) { +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] @@ -103,19 +102,19 @@ export function wikiLinkPlugin(md: Markdown, app: App) { if (!filename) { // internal page hash link return `${md.utils.escapeHtml(alias) || (titles.length ? `` : '')}` } - const internal = findFirstPage(app, filename, env.filePathRelative ?? '') - if (internal) { + const pagePath = findFirstPage(filename, env.filePathRelative ?? '') + if (pagePath) { const { absolutePath, relativePath } = resolvePaths( - internal.filePathRelative!, + pagePath, env.base || '/', env.filePathRelative ?? null, ) ;(env.links ??= []).push({ - raw: internal.filePathRelative!, + raw: pagePath, absolute: absolutePath, relative: relativePath, }) - return `${md.utils.escapeHtml(alias) || (titles.length ? `` : '')}` + return `${md.utils.escapeHtml(alias) || (titles.length ? `` : '')}` } // other asset url @@ -126,30 +125,3 @@ export function wikiLinkPlugin(md: Markdown, app: App) { }` } } - -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 bd8d2ae5..f2d849f7 100644 --- a/plugins/plugin-md-power/src/node/plugin.ts +++ b/plugins/plugin-md-power/src/node/plugin.ts @@ -13,7 +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 { obsidianPlugin, updatePagePaths } from './obsidian/index.js' import { prepareConfigFile } from './prepareConfigFile.js' import { provideData } from './provideData.js' @@ -106,13 +106,14 @@ 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) await containerPlugin(app, md, options, locales) await imageSizePlugin(app, md, options.imageSize) + + obsidianPlugin(app, md, options) }, onPrepared: async () => { @@ -133,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') + }, } } }