diff --git a/docs/en/guide/markdown/obsidian.md b/docs/en/guide/markdown/obsidian.md index 4ac5bbcd..986682dd 100644 --- a/docs/en/guide/markdown/obsidian.md +++ b/docs/en/guide/markdown/obsidian.md @@ -7,17 +7,64 @@ 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: - [Wiki Links](#wiki-links) - Syntax for inter-page linking - [Embeds](#embeds) - Embed content from other files into the current page +- [Callout](#callout) - Highlight important information with styled containers - [Comments](#comments) - Add comments visible only during editing ::: warning No plans to support extension syntax provided by Obsidian's third-party community plugins ::: +## Configuration + +Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration: + +```ts title=".vuepress/config.ts" +export default defineUserConfig({ + theme: plumeTheme({ + plugins: { + mdPower: { + obsidian: { + wikiLink: true, // Wiki Links + embedLink: true, // Embeds + callout: true, // Callout + comment: true, // Comments + }, + pdf: true, // PDF embed functionality + artPlayer: true, // Video embed functionality + } + } + }) +}) +``` + +### Configuration Options + +:::: field-group + +::: field name="wikiLink" type="boolean" default="true" optional +Enable [Wiki Links](#wiki-links) syntax. +::: + +::: field name="embedLink" type="boolean" default="true" optional +Enable [Embeds](#embeds) syntax. +::: + +::: field name="callout" type="boolean" default="true" optional +Enable [Callout](#callout) syntax. +::: + +::: field name="comment" type="boolean" default="true" optional +Enable [Comments](#comments) syntax. +::: + +:::: + ## Wiki Links Wiki Links are syntax used in Obsidian for linking to other notes. Use double brackets `[[]]` to wrap content to create internal links. @@ -67,8 +114,8 @@ In `docs/guide/markdown/obsidian.md`: | ------------------ | ----------------------------------------------------------------------------------------- | | `[[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) | +| `[[../]]` | Matches `docs/guide/README.md` (parent directory) | +| `[[guide/]]` | Matches `docs/guide/README.md` (directory form) | ### Examples @@ -222,6 +269,123 @@ Content fragments under a specified heading can be embedded using `#heading`: [Obsidian Official - **Insert Files**](https://obsidian.md/en/help/embeds){.readmore} [Obsidian Official - **File Formats**](https://obsidian.md/en/help/file-formats){.readmore} +## Callout + +Callout is a syntax for highlighting important information, similar to VuePress's `::: hint` container syntax. + +### Syntax + +```md +> [!note] +> Content +``` + +**Optional Title:** + +```md +> [!tip] Custom Title +> Content +``` + +### Types + +Callout supports the following types, with aliases automatically mapped to their corresponding primary types: + +| Type | Aliases | Description | +| ---- | ------- | ----------- | +| `note` | `quote`, `cite` | Notes, quotes | +| `tip` | `hint` | Tips, hints | +| `info` | `todo` | Information, todos | +| `success` | `check`, `done` | Success, done | +| `warning` | `question`, `help`, `faq` | Warnings, questions, help | +| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | Caution, failure, danger | +| `important` | `example` | Important, examples | +| `details` | `abstract`, `summary`, `tldr` | Details, summary | + +### Examples + +**Basic Usage:** + +**Input:** + +```md +> [!NOTE] +> This is a note callout. +``` + +**Output:** + +> [!NOTE] +> This is a note callout. + +--- + +**With Title:** + +**Input:** + +```md +> [!TIP] Useful Tip +> Using `pnpm` can significantly speed up dependency installation. +``` + +**Output:** + +> [!TIP] Useful Tip +> Using `pnpm` can significantly speed up dependency installation. + +--- + +**Multiple Types:** + +**Input:** + +```md +> [!success] +> Operation completed successfully! +> +> [!warning] +> This is a warning message. +> +> [!caution] +> Please proceed with caution, this action cannot be undone. +``` + +**Output:** + +> [!success] +> Operation completed successfully! + +> [!warning] +> This is a warning message. + +> [!caution] +> Please proceed with caution, this action cannot be undone. + +--- + +**Details Type:** + +The `details` type renders as an HTML `
` element, supporting collapse/expand: + +**Input:** + +```md +> [!details] +> Click to expand more content +> +> This is hidden content. +``` + +**Output:** + +> [!details] +> Click to expand more content +> +> This is hidden content. + +[Obsidian Official - **Callout**](https://obsidian.md/en/help/callouts){.readmore} + ## Comments Content wrapped in `%%` is treated as a comment and will not be rendered on the page. @@ -287,46 +451,6 @@ It can span multiple lines. [Obsidian Official - **Comments**](https://obsidian.md/en/help/syntax#%E6%B3%A8%E9%87%8B){.readmore} -## Configuration - -Obsidian compatibility features are all enabled by default. You can selectively enable or disable them through configuration: - -```ts title=".vuepress/config.ts" -export default defineUserConfig({ - theme: plumeTheme({ - plugins: { - mdPower: { - obsidian: { - wikiLink: true, // Wiki Links - embedLink: true, // Embeds - comment: true, // Comments - }, - pdf: true, // PDF embed functionality - artPlayer: true, // Video embed functionality - } - } - }) -}) -``` - -### Configuration Options - -:::: field-group - -::: field name="wikiLink" type="boolean" default="true" optional -Enable Wiki Links syntax. -::: - -::: field name="embedLink" type="boolean" default="true" optional -Enable embed content syntax. -::: - -::: field name="comment" type="boolean" default="true" optional -Enable comment syntax. -::: - -:::: - ## Notes - These plugins provide **compatibility support** and do not fully implement all of Obsidian's functionality diff --git a/docs/guide/markdown/obsidian.md b/docs/guide/markdown/obsidian.md index 828ed060..c556a53b 100644 --- a/docs/guide/markdown/obsidian.md +++ b/docs/guide/markdown/obsidian.md @@ -13,11 +13,57 @@ permalink: /guide/markdown/obsidian/ - [Wiki 链接](#wiki-链接) - 页面间相互链接的语法 - [嵌入内容](#嵌入内容) - 将其他文件内容嵌入到当前页面 +- [Callout](#callout) - 使用样式容器突出显示重要信息 - [注释](#注释) - 添加仅在编辑时可见的注释 ::: warning 不计划支持 Obsidian 社区第三方插件提供的扩展语法 ::: +## 配置 + +Obsidian 兼容功能默认全部启用,你可以通过配置选择性地启用或禁用: + +```ts title=".vuepress/config.ts" +export default defineUserConfig({ + theme: plumeTheme({ + plugins: { + mdPower: { + obsidian: { + wikiLink: true, // Wiki 链接 + embedLink: true, // 嵌入内容 + callout: true, // Callout + comment: true, // 注释 + }, + pdf: true, // PDF 嵌入功能 + artPlayer: true, // 视频嵌入功能 + } + } + }) +}) +``` + +### 配置项 + +:::: field-group + +::: field name="wikiLink" type="boolean" default="true" optional +启用 [Wiki 链接](#wiki-链接) 语法。 +::: + +::: field name="embedLink" type="boolean" default="true" optional +启用 [嵌入内容](#嵌入内容) 语法。 +::: + +::: field name="callout" type="boolean" default="true" optional +启用 [Callout](#callout) 语法。 +::: + +::: field name="comment" type="boolean" default="true" optional +启用 [注释](#注释) 语法。 +::: + +:::: + ## Wiki 链接 Wiki 链接是 Obsidian 中用于链接到其他笔记的语法。使用双括号 `[[]]` 包裹内容来创建内部链接。 @@ -222,6 +268,123 @@ docs/ [Obsidian 官方 - 插入文件](https://obsidian.md/zh/help/embeds){.readmore} [Obsidian 官方 - 文件格式](https://obsidian.md/zh/help/file-formats){.readmore} +## Callout + +Callout 是一种用于突出显示重要信息的语法,类似于 VuePress 的 `::: hint` 提示框语法。 + +### 语法 + +```md +> [!note] +> 内容 +``` + +**可选标题:** + +```md +> [!tip] 自定义标题 +> 内容 +``` + +### 类型 + +Callout 支持以下类型,别名会自动映射到对应的主要类型: + +| 类型 | 别名 | 说明 | +| ---- | ---- | ---- | +| `note` | `quote`, `cite` | 笔记、引用 | +| `tip` | `hint` | 技巧、提示 | +| `info` | `todo` | 信息、待办 | +| `success` | `check`, `done` | 成功、完成 | +| `warning` | `question`, `help`, `faq` | 警告、问题、帮助 | +| `caution` | `attention`, `failure`, `fail`, `missing`, `danger`, `error`, `bug` | 注意、失败、危险 | +| `important` | `example` | 重要、示例 | +| `details` | `abstract`, `summary`, `tldr` | 详情、摘要 | + +### 示例 + +**基础用法:** + +**输入:** + +```md +> [!NOTE] +> 这是一个笔记提示框。 +``` + +**输出:** + +> [!NOTE] +> 这是一个笔记提示框。 + +--- + +**带标题:** + +**输入:** + +```md +> [!TIP] 实用技巧 +> 使用 `pnpm` 可以显著加快依赖安装速度。 +``` + +**输出:** + +> [!TIP] 实用技巧 +> 使用 `pnpm` 可以显著加快依赖安装速度。 + +--- + +**多种类型:** + +**输入:** + +```md +> [!success] +> 操作成功完成! +> +> [!warning] +> 这是一个警告信息。 +> +> [!caution] +> 请谨慎操作,此操作不可撤销。 +``` + +**输出:** + +> [!success] +> 操作成功完成! + +> [!warning] +> 这是一个警告信息。 + +> [!caution] +> 请谨慎操作,此操作不可撤销。 + +--- + +**Details 类型:** + +`details` 类型会渲染为 HTML `
` 元素,支持折叠展开: + +**输入:** + +```md +> [!details] +> 点我展开更多内容 +> +> 这是一段隐藏的内容。 +``` + +**输出:** + +> [!details] +> 点我展开更多内容 +> +> 这是一段隐藏的内容。 + +[Obsidian 官方 - Callout](https://obsidian.md/zh/help/callouts){.readmore} + ## 注释 使用 `%%` 包裹的内容会被当作注释,不会渲染到页面中。 @@ -287,46 +450,6 @@ 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: { - 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 的全部功能 diff --git a/plugins/plugin-md-power/__test__/obsidianCallouts.spec.ts b/plugins/plugin-md-power/__test__/obsidianCallouts.spec.ts new file mode 100644 index 00000000..e66965b9 --- /dev/null +++ b/plugins/plugin-md-power/__test__/obsidianCallouts.spec.ts @@ -0,0 +1,917 @@ +import type { MarkdownEnv } from 'vuepress/markdown' +import MarkdownIt from 'markdown-it' +import { describe, expect, it } from 'vitest' +import { calloutPlugin } from '../src/node/obsidian/callouts.js' + +function createMockEnv(filePathRelative = 'test.md'): MarkdownEnv { + return { + filePathRelative, + base: '/', + links: [], + importedFiles: [], + } +} + +function createMarkdown() { + return new MarkdownIt({ html: true }) +} + +describe('calloutPlugin', () => { + // ==================== Primary Callout Types ==================== + + describe('primary callout types', () => { + const types = ['note', 'tip', 'info', 'success', 'warning', 'caution', 'important', 'details'] + + types.forEach((type) => { + it(`should render ${type} callout`, () => { + const md = createMarkdown().use(calloutPlugin) + // Callout format: >[!type] title on same line, content on continuation lines with > + const result = md.render(`>[!${type}]\n>\n> Content here.`) + + expect(result).toContain(`hint-container ${type}`) + expect(result).toContain('Content here') + }) + }) + + it('should render note with quote and cite aliases', () => { + const md = createMarkdown().use(calloutPlugin) + + const quoteResult = md.render('>[!quote]\n>\n> Content.') + expect(quoteResult).toContain('hint-container note') + + const citeResult = md.render('>[!cite]\n>\n> Content.') + expect(citeResult).toContain('hint-container note') + }) + + it('should render tip with hint alias', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!hint]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should render info with todo alias', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!todo]\n>\n> Content.') + expect(result).toContain('hint-container info') + }) + + it('should render success with check and done aliases', () => { + const md = createMarkdown().use(calloutPlugin) + + const checkResult = md.render('>[!check]\n>\n> Content.') + expect(checkResult).toContain('hint-container success') + + const doneResult = md.render('>[!done]\n>\n> Content.') + expect(doneResult).toContain('hint-container success') + }) + + it('should render warning with question, help, and faq aliases', () => { + const md = createMarkdown().use(calloutPlugin) + + const questionResult = md.render('>[!question]\n>\n> Content.') + expect(questionResult).toContain('hint-container warning') + + const helpResult = md.render('>[!help]\n>\n> Content.') + expect(helpResult).toContain('hint-container warning') + + const faqResult = md.render('>[!faq]\n>\n> Content.') + expect(faqResult).toContain('hint-container warning') + }) + + it('should render caution with multiple aliases', () => { + const md = createMarkdown().use(calloutPlugin) + const aliases = ['attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug'] + + aliases.forEach((alias) => { + const result = md.render(`>[!${alias}]\n>\n> Content.`) + expect(result).toContain('hint-container caution') + }) + }) + + it('should render important with example alias', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!example]\n>\n> Content.') + expect(result).toContain('hint-container important') + }) + + it('should render details with abstract, summary, and tldr aliases', () => { + const md = createMarkdown().use(calloutPlugin) + + const abstractResult = md.render('>[!abstract]\n>\n> Content.') + expect(abstractResult).toContain('hint-container details') + + const summaryResult = md.render('>[!summary]\n>\n> Content.') + expect(summaryResult).toContain('hint-container details') + + const tldrResult = md.render('>[!tldr]\n>\n> Content.') + expect(tldrResult).toContain('hint-container details') + }) + }) + + // ==================== Case Insensitivity ==================== + + describe('case insensitivity', () => { + it('should handle uppercase type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!TIP]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should handle mixed case type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!Tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should handle lowercase type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + }) + + // ==================== Title Handling ==================== + + describe('title handling', () => { + it('should render custom title text', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] Custom Title\n>\n> Content.') + expect(result).toContain('Custom Title') + }) + + it('should render title with + prefix', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] + Custom Title\n>\n> Content.') + expect(result).toContain('Custom Title') + }) + + it('should render title with - prefix', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] - Custom Title\n>\n> Content.') + expect(result).toContain('Custom Title') + }) + + it('should use default capitalized type when no title', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('Tip') + }) + + it('should render empty title with default', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] \n>\n> Content.') + expect(result).toContain('Tip') + }) + }) + + // ==================== Content Rendering ==================== + + describe('content rendering', () => { + it('should render single line content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Single line content.') + expect(result).toContain('Single line content') + }) + + it('should render multi-line content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> First paragraph. +> +> Second paragraph.`) + expect(result).toContain('First paragraph') + expect(result).toContain('Second paragraph') + }) + + it('should render nested list within callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> - Item 1 +> - Item 2 +> - Item 3`) + expect(result).toContain('Item 1') + expect(result).toContain('Item 2') + expect(result).toContain('Item 3') + }) + + it('should render heading within callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> ### Nested Heading +> +> Content after heading.`) + expect(result).toContain('Nested Heading') + expect(result).toContain('Content after heading') + }) + + it('should render code block within callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> \`\`\`js +> const x = 1; +> \`\`\``) + expect(result).toContain('const x = 1') + }) + + it('should parse content after callout correctly', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Callout content. +> +After callout paragraph. + +## Heading after + +More content.`) + + expect(result).toContain('Callout content') + expect(result).toContain('After callout paragraph') + expect(result).toContain('Heading after') + }) + }) + + // ==================== Syntax Variations ==================== + + describe('syntax variations', () => { + it('should parse without space after >', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should parse with space after >', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('> [!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should parse with multiple spaces', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('> [!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should parse with tab after >', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>\t[!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should handle tab with space alignment', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(' >\t [!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + }) + + // ==================== Block Parsing Edge Cases ==================== + + describe('block parsing edge cases', () => { + it('should terminate on empty line outside callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content. +> +> More content. + +After callout.`) + + expect(result).toContain('Content') + expect(result).toContain('More content') + expect(result).toContain('After callout') + }) + + it('should terminate on outdented content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content. + outdented line`) + + expect(result).toContain('Content') + expect(result).toContain('outdented line') + }) + + it('should handle outdented line as block terminator (line 265)', () => { + // When the content line is outdented (sCount < blkIndent), the callout ends + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. List item + >[!tip] + > + > Content. + +2. Next item`) + + expect(result).toContain('Content') + expect(result).toContain('Next item') + expect(result).toContain('hint-container tip') + }) + + it('should terminate on horizontal rule', () => { + const md = createMarkdown().use(calloutPlugin) + // Using *** instead of - - - to ensure it's recognized as horizontal rule + const result = md.render(`>[!tip] +> +> Content. +> +> ***`) + + expect(result).toContain('Content') + }) + + it('should terminate when terminator rule matches (lines 280-281)', () => { + // The terminator rule for blockquote will match when the callout is properly terminated + // by another blockquote-like structure + const md = createMarkdown().use(calloutPlugin) + // After the horizontal rule, the content below should be separate + const result = md.render(`>[!tip] +> +> Content. + +--- + +After horizontal rule.`) + + expect(result).toContain('Content') + expect(result).toContain('After horizontal rule') + }) + + it('should handle list terminator correctly (lines 280-281)', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Callout content. +> +> 1. Ordered list inside`) + + expect(result).toContain('Callout content') + expect(result).toContain('Ordered list inside') + }) + + it('should terminate on list item', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content. +> +> 1. Ordered item`) + + expect(result).toContain('Content') + }) + + it('should handle continuation after blockquote in list', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. List item + >[!tip] + > + > Content in callout. + > + > More content. + +2. Next list item`) + + expect(result).toContain('Content in callout') + expect(result).toContain('More content') + expect(result).toContain('Next list item') + }) + + it('should restore state correctly when blkIndent !== 0 (lines 290-304)', () => { + // When callout is in a list item (non-zero blkIndent) and is terminated + // by another block, the blkIndent adjustment should be restored + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. List item + >[!tip] + > + > Callout content. + > + > ---`) + + expect(result).toContain('Callout content') + expect(result).toContain('hint-container tip') + }) + + it('should handle nested blockquote in callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> > Nested quote +> +> Content after nested.`) + expect(result).toContain('Nested quote') + expect(result).toContain('Content after nested') + }) + + it('should handle callout with proper terminator restoration', () => { + // Test for lines 290-304: blkIndent restoration when terminated by other block + // This requires the callout to be inside a list with non-zero blkIndent + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. List item + >[!tip] + > + > Callout content. + > + > More content. + > + > - - - + +2. Next item`) + + expect(result).toContain('Callout content') + expect(result).toContain('More content') + expect(result).toContain('hint-container tip') + expect(result).toContain('Next item') + }) + }) + + // ==================== Invalid Syntax ==================== + + describe('invalid syntax', () => { + it('should not parse unknown type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!unknown]\n>\n> Content.') + expect(result).not.toContain('hint-container') + expect(result).toContain('Content') + }) + + it('should not parse empty type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[]\n>\n> Content.') + expect(result).not.toContain('hint-container') + }) + + it('should not parse incomplete syntax without closing bracket', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip\n>\n> Content.') + expect(result).not.toContain('hint-container') + }) + + it('should not parse without opening bracket', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>!tip]\n>\n> Content.') + expect(result).not.toContain('hint-container') + }) + + it('should not parse without > marker', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('[!tip]\n>\n> Content.') + expect(result).not.toContain('hint-container') + }) + + it('should not parse when indented more than 3 spaces (becomes code)', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(' >[!tip]\n>\n> Content.') + expect(result).not.toContain('hint-container') + expect(result).toContain('= 4 (line 44)', () => { + // Line 44: if sCount - blkIndent >= 4, return false + // This would happen when a line is deeply indented beyond the block indent + // In practice, blkIndent tracks sCount in list contexts, making this hard to trigger + // We test with a deeply indented block that exceeds normal block processing + const md = createMarkdown().use(calloutPlugin) + // This scenario exercises the code path even if the exact condition is hard to isolate + const result = md.render('> [!tip]\n>\n> Content.') + // With 5 spaces after >, offset-initial=4 triggers line 96 first + // But the overall block parsing exercises related code paths + expect(result).not.toContain('hint-container') + }) + + it('should not parse type only without brackets', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>tip]\n>\n> Content.') + expect(result).not.toContain('hint-container') + }) + + it('should not parse empty callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]') + expect(result).not.toContain('hint-container') + }) + + it('should not parse callout with only empty continuation lines', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +>`) + expect(result).not.toContain('hint-container') + }) + + it('should return false when offset - initial >= 4 (line 96)', () => { + // Line 96: offset - initial >= 4 means 4+ spaces after > before the callout type + // > [!tip] has 5 spaces after >, so offset - initial = 4 >= 4, returns false + // This causes it to be treated as a code block within blockquote + const md = createMarkdown().use(calloutPlugin) + const result = md.render('> [!tip]\n>\n> Content.') + expect(result).not.toContain('hint-container') + // It should be treated as blockquote with code-like content + expect(result).toContain(' { + it('should render details as details element', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!details]\n>\n> Content.') + expect(result).toContain('') + expect(result).toContain('') + }) + + it('should not use div for details type', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!details]\n>\n> Content.') + expect(result).not.toContain('
{ + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!details] Summary Title\n>\n> Content.') + expect(result).toContain(' { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!abstract]\n>\n> Content.') + expect(result).toContain(' { + it('should render alert_open with correct class', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('hint-container tip') + }) + + it('should render alert_title with correct class', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] Title\n>\n> Content.') + expect(result).toContain('hint-container-title') + }) + + it('should render opening and closing tags', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('') + }) + + it('should render hint-container class', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('hint-container') + }) + }) + + // ==================== Locale Support ==================== + + describe('locale support', () => { + it('should use default type name when no locale match', () => { + const md = createMarkdown().use(calloutPlugin, { + locales: {}, + }) + const result = md.render('>[!tip]\n>\n> Content.') + expect(result).toContain('Tip') + }) + + it('should use custom locale title when provided', () => { + const md = createMarkdown().use(calloutPlugin, { + locales: { + '/': { + tip: 'Custom Tip Title', + }, + }, + }) + const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('/')) + expect(result).toContain('Custom Tip Title') + }) + + it('should use locale for specific path', () => { + const md = createMarkdown().use(calloutPlugin, { + locales: { + '/zh/': { + tip: '提示', + }, + }, + }) + const result = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md')) + expect(result).toContain('提示') + }) + + it('should prefer custom locale over default', () => { + const md = createMarkdown().use(calloutPlugin, { + locales: { + '/': { + tip: 'Default Tip', + }, + '/zh/': { + tip: '中文提示', + }, + }, + }) + + const defaultResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('guide.md')) + expect(defaultResult).toContain('Default Tip') + + const zhResult = md.render('>[!tip]\n>\n> Content.', createMockEnv('zh/guide.md')) + expect(zhResult).toContain('中文提示') + }) + + it('should handle locale without matching type', () => { + const md = createMarkdown().use(calloutPlugin, { + locales: { + '/': { + note: 'Note Title', + }, + }, + }) + const result = md.render('>[!tip]\n>\n> Content.') + // Should still use capitalized type as fallback + expect(result).toContain('Tip') + }) + }) + + // ==================== Edge Cases ==================== + + describe('edge cases', () => { + it('should handle callout at beginning of document', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> First content. + +Second paragraph.`) + expect(result).toContain('First content') + expect(result).toContain('Second paragraph') + }) + + it('should handle multiple callouts in sequence', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Tip content. + +>[!warning] +> +> Warning content. + +>[!note] +> +> Note content.`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Tip content') + expect(result).toContain('hint-container warning') + expect(result).toContain('Warning content') + expect(result).toContain('hint-container note') + expect(result).toContain('Note content') + }) + + it('should handle empty lines within callout', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Line 1. +> +> +> Line 2.`) + + expect(result).toContain('Line 1') + expect(result).toContain('Line 2') + }) + + it('should handle adjacent callouts without blank line', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> First. +>[!note] +> +> Second.`) + + expect(result).toContain('First') + expect(result).toContain('Second') + }) + + it('should not interfere with regular blockquotes', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`> Regular blockquote +> +> Another line.`) + + expect(result).not.toContain('hint-container') + expect(result).toContain('Regular blockquote') + }) + + it('should handle indented callout in list', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`- List item + >[!tip] + > + > Indented callout.`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Indented callout') + }) + + it('should handle unicode in title', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] 中文标题\n>\n> Content.') + expect(result).toContain('中文标题') + }) + + it('should handle emoji in title', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip] 🚀 Launch\n>\n> Content.') + expect(result).toContain('🚀') + expect(result).toContain('Launch') + }) + }) + + // ==================== Inline Content Rendering ==================== + + describe('inline content rendering', () => { + it('should render inline code in content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Use `code` inline.') + expect(result).toContain('code') + }) + + it('should render links in content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> Check [this](https://example.com).') + expect(result).toContain(' { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> This is *italic* and **bold**.') + expect(result).toContain('italic') + expect(result).toContain('bold') + }) + + it('should render strikethrough in content', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render('>[!tip]\n>\n> ~~Deleted~~ text.') + expect(result).toContain('Deleted') + }) + }) + + // ==================== Code Block Type Detection ==================== + + describe('code block type detection', () => { + it('should detect as code block when indented 4+ spaces', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(` >[!tip] Title +> +> Content.`) + + expect(result).toContain('= 4', () => { + it('should return false when deeply indented and code rule is disabled', () => { + const md = createMarkdown().use(calloutPlugin) + md.block.ruler.disable('code') + const result = md.render(' >[!tip]\n>\n> Content.') + + expect(result).not.toContain('hint-container') + }) + + it('should return false when deeply indented inside list with code rule disabled', () => { + const md = createMarkdown().use(calloutPlugin) + md.block.ruler.disable('code') + const result = md.render(`- Item + >[!tip] + > + > Content`) + + expect(result).not.toContain('hint-container tip') + }) + }) + + describe('isOutdented break inside list', () => { + it('should break when body line is outdented from list context', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`- Item + >[!tip] + > + > Content +Outdented line`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Content') + expect(result).toContain('Outdented line') + }) + + it('should break when callout body reaches line outside list indent', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. Item + >[!warning] + > + > Content +Outside list`) + + expect(result).toContain('hint-container warning') + expect(result).toContain('Content') + expect(result).toContain('Outside list') + }) + }) + + describe('terminator rule matches', () => { + it('should terminate callout when horizontal rule follows without blank line', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content +---`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Content') + expect(result).toContain(' { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content +## Heading`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Content') + expect(result).toContain('Heading') + }) + + it('should terminate callout when fence block follows without blank line', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`>[!tip] +> +> Content +\`\`\`js +code +\`\`\``) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Content') + expect(result).toContain(' { + it('should adjust sCount when callout in list is terminated by hr', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`- Item + >[!tip] + > + > Content + ---`) + + expect(result).toContain('hint-container tip') + expect(result).toContain('Content') + }) + + it('should adjust sCount when callout in ordered list is terminated by hr', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`1. Item + >[!caution] + > + > Content + ---`) + + expect(result).toContain('hint-container caution') + expect(result).toContain('Content') + }) + + it('should handle callout in list terminated by fence', () => { + const md = createMarkdown().use(calloutPlugin) + const result = md.render(`- Item + >[!note] + > + > Content + \`\`\` + code + \`\`\``) + + expect(result).toContain('hint-container note') + expect(result).toContain('Content') + }) + }) +}) diff --git a/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts b/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts index a63de2a2..a3a4460a 100644 --- a/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts +++ b/plugins/plugin-md-power/__test__/obsidianEmbedLink.spec.ts @@ -37,6 +37,26 @@ vi.mock('@vuepress/helper', () => ({ removeLeadingSlash: vi.fn((p: string) => p.replace(/^\//, '')), })) +vi.mock('@vuepress/shared', () => ({ + ensureLeadingSlash: vi.fn((p: string) => (p[0] === '/' ? p : `/${p}`)), + isLinkHttp: vi.fn((p: string) => p.startsWith('http://') || p.startsWith('https://')), +})) + +vi.mock('../src/node/enhance/links.js', () => ({ + resolvePaths: vi.fn((rawPath: string) => ({ + absolutePath: `/${rawPath}`, + relativePath: rawPath, + })), +})) + +vi.mock('../src/node/utils/slugify.js', () => ({ + slugify: vi.fn((s: string) => s.toLowerCase().replace(/\s+/g, '-')), +})) + +vi.mock('../src/node/utils/cleanMarkdownEnv.js', () => ({ + cleanMarkdownEnv: vi.fn((env: MarkdownEnv) => env), +})) + function createMockApp(pages: App['pages'] = []): App { return { pages, @@ -337,6 +357,28 @@ Regular content. expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('can not read file')) warnSpy.mockRestore() }) + + it('should return empty string when file has only frontmatter', () => { + // gray-matter will extract empty content when file has only frontmatter + mockGlobSync.mockReturnValue(['empty.md']) + mockReadFileSync.mockReturnValue(`--- +title: Empty +--- +`) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, app) + const env = createMockEnv() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const result = md.render('![[empty]]', env) + + expect(result).toBe('') + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('is empty')) + warnSpy.mockRestore() + }) }) // ==================== Heading Search Edge Cases ==================== @@ -397,6 +439,63 @@ Content.` expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No heading found')) warnSpy.mockRestore() }) + + it('should reset search when encountering same-level heading with different text', () => { + const content = `# A + +## B + +B content. + +## C + +C content.` + + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockReturnValue(content) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + // Searching for A > B > C, but C is at same level as B, not nested under it + const result = md.render('![[guide#A#B#C]]', env) + + expect(result).toBe('') + }) + + it('should reset headingPointer when first heading reappears at shallower level', () => { + // Structure: # A, ## B, ## A + // When searching for A > B > A, after finding A at level 1 and B at level 2, + // we encounter A again at level 2 which is <= currentLevel and matches headings[0] + const content = `# A + +## B + +B content. + +## A + +A content again.` + + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockReturnValue(content) + + const app = createMockApp() + initPagePaths(app) + + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + // Searching for A > B > A + // After finding A (level 1) and B (level 2), we find A at level 2 + // level 2 <= currentLevel 2 is true, and A === headings[0], so we reset + const result = md.render('![[guide#A#B#A]]', env) + + expect(result).toBe('') + }) }) // ==================== Edge Cases ==================== @@ -418,4 +517,236 @@ Content.` expect(result).toContain('![[]]') }) }) + + // ==================== Inline Embed Link ==================== + + describe('inline embed link', () => { + beforeEach(() => { + mockGlobSync.mockReturnValue([]) + const app = createMockApp() + initPagePaths(app) + }) + + it('should parse inline image embed within text', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Here is an image ![[photo.png]] in text.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Listen to ![[music.mp3]] this.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Watch ![[clip.mp4]] this video.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('See ![[doc.pdf]] for details.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Check ![[https://example.com/link]] out.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Image ![[photo.png|400x300]] here.') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Here ![[image.png] is text.') + expect(result).toContain('![[image.png]') + }) + + it('should handle inline embed with empty content', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('Here ![[]] is text.') + // Empty embed is parsed and rendered as an external link + expect(result).toContain(' { + const guideContent = `--- +title: Guide +--- + +# Introduction + +This is intro content. + +## Getting Started + +Steps for getting started. +` + + beforeEach(() => { + mockGlobSync.mockReturnValue(['guide.md']) + mockReadFileSync.mockReturnValue(guideContent) + + const app = createMockApp() + initPagePaths(app) + }) + + it('should render inline markdown page embed as VPLink', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('See ![[guide]] for details.', env) + + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('See ![[guide#Introduction]] for details.', env) + + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('See ![[guide|Custom Text]] for details.', env) + + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('See ![[guide#Introduction#Getting Started]] for details.', env) + + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + md.render('See ![[guide]] for details.', env) + + expect(env.links).toContainEqual( + expect.objectContaining({ + raw: 'guide.md', + }), + ) + }) + + it('should render inline embed with relative path as external link when not found', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv('docs/page.md') + + const result = md.render('See ![[./guide]] for details.', env) + + // ./guide doesn't resolve to a page in findFirstPage, so renders as external link + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('See ![[nonexistent]] for details.', env) + + expect(result).toContain(' { + beforeEach(() => { + mockGlobSync.mockReturnValue([]) + }) + + it('should render inline external link with anchor', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('Check ![[https://example.com/page#section]] out.', env) + + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const env = createMockEnv() + + const result = md.render('Check ![[https://example.com/doc.md#intro]] out.', env) + + expect(result).toContain(' { + beforeEach(() => { + mockGlobSync.mockReturnValue([]) + }) + + it('should handle block embed of image with pipe settings', () => { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[banner.jpg|800x200]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[narration.mp3]]') + expect(result).toContain(' { + const md = createMarkdownWithMockRules().use(embedLinkPlugin, createMockApp()) + const result = md.render('![[presentation.mp4]]') + expect(result).toContain(' { it('should enable all plugins by default', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp([{ path: '/', filePathRelative: 'README.md', title: 'Home' }] as unknown as App['pages']) - obsidianPlugin(mockApp, md, {}) + obsidianPlugin(mockApp, md, {}, {}) // Wiki link should not work since findFirstPage returns undefined when pagePaths is empty const wikiResult = md.render('[[Home]]') @@ -49,7 +49,7 @@ describe('obsidianPlugin', () => { it('should allow disabling specific plugins', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(mockApp, md, { 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(mockApp, md, { obsidian: false }) + obsidianPlugin(mockApp, md, { obsidian: false }, {}) const result = md.render('![[image.png]]') expect(result).toContain('![[image.png]]') @@ -68,7 +68,7 @@ describe('obsidianPlugin', () => { it('should disable embedLink when explicitly set to false', () => { const md = createMarkdownWithMockRules() const mockApp = createMockApp() - obsidianPlugin(mockApp, md, { 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(mockApp, md, { obsidian: { comment: false } }) + obsidianPlugin(mockApp, md, { obsidian: { comment: false } }, {}) const commentResult = md.render('%%comment%%') expect(commentResult).toContain('%%comment%%') diff --git a/plugins/plugin-md-power/src/node/locales/de.ts b/plugins/plugin-md-power/src/node/locales/de.ts index 581034ad..f5fabde6 100644 --- a/plugins/plugin-md-power/src/node/locales/de.ts +++ b/plugins/plugin-md-power/src/node/locales/de.ts @@ -13,4 +13,34 @@ export const deLocale: MDPowerLocaleData = { warningTitle: '🚨 Sicherheitswarnung:', warningText: 'Ihre Verbindung ist nicht mit HTTPS verschlüsselt, was ein Risiko für Inhaltslecks darstellt und den Zugriff auf verschlüsselte Inhalte verhindert.', }, + obsidian: { + note: 'Hinweis', + quote: 'Zitat', + cite: 'Quellenangabe', + tip: 'Tipp', + hint: 'Hinweis', + info: 'Info', + todo: 'Aufgabe', + success: 'Erfolg', + check: 'Prüfung', + done: 'Erledigt', + warning: 'Warnung', + question: 'Frage', + help: 'Hilfe', + faq: 'FAQ', + caution: 'Vorsicht', + attention: 'Achtung', + failure: 'Fehlschlag', + fail: 'Gescheitert', + missing: 'Fehlend', + danger: 'Gefahr', + error: 'Fehler', + bug: 'Bug', + important: 'Wichtig', + example: 'Beispiel', + details: 'Details', + abstract: 'Zusammenfassung', + summary: 'Zusammenfassung', + tldr: 'TL;DR', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/en.ts b/plugins/plugin-md-power/src/node/locales/en.ts index 61eede7f..a0c4ad70 100644 --- a/plugins/plugin-md-power/src/node/locales/en.ts +++ b/plugins/plugin-md-power/src/node/locales/en.ts @@ -13,4 +13,34 @@ export const enLocale: MDPowerLocaleData = { warningTitle: '🚨 Security Warning:', warningText: 'Your connection is not encrypted with HTTPS, posing a risk of content leakage and preventing access to encrypted content.', }, + obsidian: { + note: 'Note', + quote: 'Quote', + cite: 'Cite', + tip: 'Tip', + hint: 'Hint', + info: 'Info', + todo: 'Todo', + success: 'Success', + check: 'Check', + done: 'Done', + warning: 'Warning', + question: 'Question', + help: 'Help', + faq: 'FAQ', + caution: 'Caution', + attention: 'Attention', + failure: 'Failure', + fail: 'Fail', + missing: 'Missing', + danger: 'Danger', + error: 'Error', + bug: 'Bug', + important: 'Important', + example: 'Example', + details: 'Details', + abstract: 'Abstract', + summary: 'Summary', + tldr: 'TL;DR', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/fr.ts b/plugins/plugin-md-power/src/node/locales/fr.ts index 02d28f14..03c9371b 100644 --- a/plugins/plugin-md-power/src/node/locales/fr.ts +++ b/plugins/plugin-md-power/src/node/locales/fr.ts @@ -13,4 +13,34 @@ export const frLocale: MDPowerLocaleData = { warningTitle: '🚨 Avertissement de sécurité :', warningText: 'Votre connexion n\'est pas chiffrée avec HTTPS, ce qui présente un risque de fuite de contenu et empêche l\'accès au contenu chiffré.', }, + obsidian: { + note: 'Note', + quote: 'Citation', + cite: 'Référence', + tip: 'Astuce', + hint: 'Indice', + info: 'Info', + todo: 'À faire', + success: 'Succès', + check: 'Vérification', + done: 'Terminé', + warning: 'Avertissement', + question: 'Question', + help: 'Aide', + faq: 'FAQ', + caution: 'Prudence', + attention: 'Attention', + failure: 'Échec', + fail: 'Échoué', + missing: 'Manquant', + danger: 'Danger', + error: 'Erreur', + bug: 'Bug', + important: 'Important', + example: 'Exemple', + details: 'Détails', + abstract: 'Résumé', + summary: 'Sommaire', + tldr: 'En bref', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/ja.ts b/plugins/plugin-md-power/src/node/locales/ja.ts index 7b19a91f..b693412a 100644 --- a/plugins/plugin-md-power/src/node/locales/ja.ts +++ b/plugins/plugin-md-power/src/node/locales/ja.ts @@ -13,4 +13,34 @@ export const jaLocale: MDPowerLocaleData = { warningTitle: '🚨 セキュリティ警告:', warningText: '接続がHTTPSで暗号化されていないため、コンテンツの漏洩リスクがあり、暗号化されたコンテンツへのアクセスができません。', }, + obsidian: { + note: 'ノート', + quote: '引用', + cite: '出典', + tip: 'ヒント', + hint: '助言', + info: '情報', + todo: 'やること', + success: '成功', + check: 'チェック', + done: '完了', + warning: '警告', + question: '質問', + help: 'ヘルプ', + faq: 'よくある質問', + caution: '注意', + attention: '注目', + failure: '失敗', + fail: '未達', + missing: '不明', + danger: '危険', + error: 'エラー', + bug: 'バグ', + important: '重要', + example: '例', + details: '詳細', + abstract: '要約', + summary: 'まとめ', + tldr: '短縮版', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/ko.ts b/plugins/plugin-md-power/src/node/locales/ko.ts index a35eba9f..d2376ed9 100644 --- a/plugins/plugin-md-power/src/node/locales/ko.ts +++ b/plugins/plugin-md-power/src/node/locales/ko.ts @@ -13,4 +13,34 @@ export const koLocale: MDPowerLocaleData = { warningTitle: '🚨 보안 경고:', warningText: '연결이 HTTPS로 암호화되지 않아 내용 유출 위험이 있으며, 암호화된 콘텐츠에 접근할 수 없습니다.', }, + obsidian: { + note: '참고', + quote: '인용', + cite: '인용문', + tip: '팁', + hint: '단서', + info: '정보', + todo: '할 일', + success: '성공', + check: '확인', + done: '완료', + warning: '경고', + question: '질문', + help: '도움말', + faq: '자주 묻는 질문', + caution: '주의', + attention: '주목', + failure: '실패', + fail: '미달', + missing: '누락', + danger: '위험', + error: '오류', + bug: '버그', + important: '중요', + example: '예시', + details: '세부사항', + abstract: '요약', + summary: '정리', + tldr: '요약', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/ru.ts b/plugins/plugin-md-power/src/node/locales/ru.ts index 2ae6b31b..c80a3fd6 100644 --- a/plugins/plugin-md-power/src/node/locales/ru.ts +++ b/plugins/plugin-md-power/src/node/locales/ru.ts @@ -13,4 +13,34 @@ export const ruLocale: MDPowerLocaleData = { warningTitle: '🚨 Предупреждение безопасности:', warningText: 'Ваше соединение не защищено HTTPS, что создает риск утечки данных и блокирует доступ к зашифрованному контенту.', }, + obsidian: { + note: 'Заметка', + quote: 'Цитата', + cite: 'Ссылка', + tip: 'Совет', + hint: 'Подсказка', + info: 'Информация', + todo: 'Сделать', + success: 'Успех', + check: 'Проверка', + done: 'Готово', + warning: 'Предупреждение', + question: 'Вопрос', + help: 'Помощь', + faq: 'ЧаВо', + caution: 'Осторожно', + attention: 'Внимание', + failure: 'Неудача', + fail: 'Сбой', + missing: 'Отсутствует', + danger: 'Опасность', + error: 'Ошибка', + bug: 'Баг', + important: 'Важно', + example: 'Пример', + details: 'Подробности', + abstract: 'Аннотация', + summary: 'Итоги', + tldr: 'Кратко', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/zh-tw.ts b/plugins/plugin-md-power/src/node/locales/zh-tw.ts index 832b95f8..aa6de322 100644 --- a/plugins/plugin-md-power/src/node/locales/zh-tw.ts +++ b/plugins/plugin-md-power/src/node/locales/zh-tw.ts @@ -13,4 +13,34 @@ export const zhTWLocale: MDPowerLocaleData = { warningTitle: '🚨 安全警告:', warningText: '您的連線未使用 HTTPS 加密,可能導致內容洩露風險,無法存取加密內容。', }, + obsidian: { + note: '筆記', + quote: '引用', + cite: '引文', + tip: '提示', + hint: '技巧', + info: '資訊', + todo: '待辦', + success: '成功', + check: '核對', + done: '完成', + warning: '警告', + question: '疑問', + help: '幫助', + faq: '常見問題', + caution: '注意', + attention: '關注', + failure: '失敗', + fail: '未通過', + missing: '缺失', + danger: '危險', + error: '錯誤', + bug: '缺陷', + important: '重要', + example: '範例', + details: '詳情', + abstract: '摘要', + summary: '總結', + tldr: '太長不看', + }, } diff --git a/plugins/plugin-md-power/src/node/locales/zh.ts b/plugins/plugin-md-power/src/node/locales/zh.ts index 3dd37705..99fcf2ea 100644 --- a/plugins/plugin-md-power/src/node/locales/zh.ts +++ b/plugins/plugin-md-power/src/node/locales/zh.ts @@ -13,4 +13,34 @@ export const zhLocale: MDPowerLocaleData = { warningTitle: '🚨 安全警告:', warningText: '您的连接未使用HTTPS加密,存在内容泄露风险,无法访问加密内容。', }, + obsidian: { + note: '笔记', + quote: '引用', + cite: '引文', + tip: '提示', + hint: '技巧', + info: '信息', + todo: '待办', + success: '成功', + check: '核对', + done: '完成', + warning: '警告', + question: '疑问', + help: '帮助', + faq: '常见问题', + caution: '注意', + attention: '关注', + failure: '失败', + fail: '未通过', + missing: '缺失', + danger: '危险', + error: '错误', + bug: '缺陷', + important: '重要', + example: '示例', + details: '详情', + abstract: '摘要', + summary: '总结', + tldr: '太长不看', + }, } diff --git a/plugins/plugin-md-power/src/node/obsidian/callouts.ts b/plugins/plugin-md-power/src/node/obsidian/callouts.ts new file mode 100644 index 00000000..d0f6d4d2 --- /dev/null +++ b/plugins/plugin-md-power/src/node/obsidian/callouts.ts @@ -0,0 +1,431 @@ +/** + * obsidian callouts,其表现形式类似于 vuepress alerts + * + * 差异点:在语法上支持 标题 和 折叠展开 + * + * 1. 此插件将 callouts 所有类型映射到 vuepress alerts 支持的类型中 + * 2. 忽略 折叠展开功能,在 语法解析上 不处理 折叠展开逻辑 + * 3. 支持 标题 + * + * @see - https://obsidian.md/zh/help/callouts + */ + +import type { PluginWithOptions } from 'markdown-it' +import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' +import type { MarkdownEnv } from 'vuepress/markdown' +import type { ObsidianCalloutOptions } from '../../shared/index.js' +import { capitalize, objectEntries, uniq } from '@pengzhanbo/utils' +import { ensureLeadingSlash } from '@vuepress/helper' +import { resolveLocalePath } from 'vuepress/shared' +import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js' + +// 将 obsidian callout 映射到 vuepress alert 的类型 +const calloutsToAlerts: Record = { + note: ['quote', 'cite'], + tip: ['hint'], + info: ['todo'], + success: ['check', 'done'], + warning: ['question', 'help', 'faq'], + caution: ['attention', 'failure', 'fail', 'missing', 'danger', 'error', 'bug'], + important: ['example'], + details: ['abstract', 'summary', 'tldr'], +} + +const callouts = objectEntries(calloutsToAlerts).map(([k, v]) => [k, ...v]).flat() +const calloutAlias = objectEntries(calloutsToAlerts) + .reduce((acc, [k, v]) => { + v.forEach(alias => acc[alias] = k) + return acc + }, {} as Record) + +const calloutsDef: RuleBlock = (state, startLine, endLine, silent) => { + // if it's indented more than 3 spaces, it should be a code block + if (state.sCount[startLine] - state.blkIndent >= 4) + return false + + let pos = state.bMarks[startLine] + state.tShift[startLine] + let max = state.eMarks[startLine] + + // check the block quote marker + if (state.src.charCodeAt(pos) !== 62 /* > */) + return false + + let currentPos = pos + 1 + + let initial = state.sCount[startLine] + 1 + let adjustTab = false + + // skip one optional space after '>' + if (state.src.charCodeAt(currentPos) === 32 /* space */) { + // ' > [!tip] ' + // ^ -- position start of line here: + currentPos++ + initial++ + } + else if (state.src.charCodeAt(currentPos) === 9 /* tab */) { + if ((state.bsCount[startLine] + initial) % 4 === 3) { + // ' >\t [!tip] ' + // ^ -- position start of line here (tab has width===1) + currentPos++ + initial++ + } + else { + // ' >\t [!tip] ' + // ^ -- position start of line here + shift bsCount slightly + // to make extra space appear + adjustTab = true + } + } + + let offset = initial + + while (currentPos < max) { + const ch = state.src.charCodeAt(currentPos) + + if (ch === 9 /** \t */) + offset += 4 - ((offset + state.bsCount[startLine] + (adjustTab ? 1 : 0)) % 4) + else if (ch === 32 /** Space */) + offset++ + else break + + currentPos++ + } + + // skip blockquote + if (offset - initial >= 4) + return false + + // the minimum length of an alert is 4 characters [!x] + if (max - currentPos < 4) + return false + + // check opening marker '[!' + if ( + state.src.charCodeAt(currentPos) !== 91 + || /* [ */ state.src.charCodeAt(currentPos + 1) !== 33 /* ! */ + ) { + return false + } + + currentPos += 2 + + let typeName = '' + + // find closing bracket ']' + while (currentPos < max) { + const char = state.src.charAt(currentPos) + + if (char === ']') + break + + typeName += char + currentPos++ + } + + if (currentPos === max) + return false + + const type = typeName.toLowerCase() + + if (!callouts.includes(type)) + return false + + // skip spaces after ']' + currentPos = state.skipSpaces(currentPos + 1) + + // if there are non-space characters after ']', it's not a valid alert + // if (currentPos < max) + // return false + const titleContent = state.src.slice(currentPos, max) + + const oldBMarks: number[] = [] + const oldBSCount: number[] = [] + const oldSCount: number[] = [] + const oldTShift: number[] = [] + const oldLineMax = state.lineMax + const oldParentType = state.parentType + const terminatorRules = [ + state.md.block.ruler.getRules('blockquote'), + state.md.block.ruler.getRules('alert'), + ].flat() + + // @ts-expect-error: We are creating a new type called "alert" + state.parentType = 'alert' + + // Search the end of the block + // + // Block ends with either: + // 1. an empty line outside: + // ``` + // > test + // + // ``` + // 2. an empty line inside: + // ``` + // > + // test + // ``` + // 3. another tag: + // ``` + // > test + // - - - + // ``` + let currentLine = startLine + let lastLineEmpty = false + let hasBodyContent = false + + for (; currentLine < endLine; currentLine++) { + // check if it's outdented, i.e. it's inside list item and indented + // less than said list item: + // + // ``` + // 1. anything + // > current blockquote + // 2. checking this line + // ``` + const isOutdented = state.sCount[currentLine] < state.blkIndent + + pos = state.bMarks[currentLine] + state.tShift[currentLine] + max = state.eMarks[currentLine] + + // Case 1: line is not inside the blockquote, and this line is empty. + if (pos >= max) + break + + if (state.src.charCodeAt(pos++) === 62 /* > */ && !isOutdented) { + // This line is inside the blockquote. + + let spaceAfterMarker = false + // set offset past spaces and ">" + initial = state.sCount[currentLine] + 1 + adjustTab = false + + // skip one optional space after '>' + if (state.src.charCodeAt(pos) === 32 /* space */) { + // ' > test ' + // ^ -- position start of line here: + pos++ + initial++ + spaceAfterMarker = true + } + else if (state.src.charCodeAt(pos) === 9 /* \t */) { + spaceAfterMarker = true + + if ((state.bsCount[currentLine] + initial) % 4 === 3) { + // ' >\t test ' + // ^ -- position start of line here (tab has width===1) + pos++ + initial++ + } + else { + // ' >\t test ' + // ^ -- position start of line here + shift bsCount slightly + // to make extra space appear + adjustTab = true + } + } + + offset = initial + + if (!silent) { + oldBMarks.push(state.bMarks[currentLine]) + state.bMarks[currentLine] = pos + } + + while (pos < max) { + const ch = state.src.charCodeAt(pos) + + if (ch === 9 /** \t */) + offset += 4 - ((offset + state.bsCount[currentLine] + (adjustTab ? 1 : 0)) % 4) + else if (ch === 32 /** Space */) + offset++ + else break + + pos++ + } + + lastLineEmpty = pos >= max + if (currentLine > startLine && !lastLineEmpty) + hasBodyContent = true + + if (!silent) { + oldBSCount.push(state.bsCount[currentLine]) + state.bsCount[currentLine] = state.sCount[currentLine] + 1 + (spaceAfterMarker ? 1 : 0) + + oldSCount.push(state.sCount[currentLine]) + state.sCount[currentLine] = offset - initial + + oldTShift.push(state.tShift[currentLine]) + state.tShift[currentLine] = pos - state.bMarks[currentLine] + } + continue + } + + if (isOutdented) + break + + // Case 2: line is not inside the blockquote, and the last line was empty. + if (lastLineEmpty) + break + + // Case 3: another tag found. + let terminate = false + + const terminateRuleLength = terminatorRules.length + + for (let i = 0; i < terminateRuleLength; i++) { + const terminatorRule = terminatorRules[i] + + if (terminatorRule(state, currentLine, endLine, true)) { + terminate = true + break + } + } + + if (terminate) { + // Quirk to enforce "hard termination mode" for paragraphs; + // normally if you call `tokenize(state, startLine, nextLine)`, + // paragraphs will look below nextLine for paragraph continuation, + // but if blockquote is terminated by another tag, they shouldn't + state.lineMax = currentLine + + if (state.blkIndent !== 0 && !silent) { + // state.blkIndent was non-zero, we now set it to zero, + // so we need to re-calculate all offsets to appear as + // if indent wasn't changed + oldBMarks.push(state.bMarks[currentLine]) + oldBSCount.push(state.bsCount[currentLine]) + oldSCount.push(state.sCount[currentLine]) + oldTShift.push(state.tShift[currentLine]) + + state.sCount[currentLine] -= state.blkIndent + } + + break + } + + hasBodyContent = true + + if (!silent) { + oldBMarks.push(state.bMarks[currentLine]) + oldBSCount.push(state.bsCount[currentLine]) + oldSCount.push(state.sCount[currentLine]) + oldTShift.push(state.tShift[currentLine]) + + // A negative indentation means that this is a paragraph continuation + // we only set it if it's not the first line of the body + if (currentLine > startLine + 1) + state.sCount[currentLine] = -1 + } + } + + const restoreState = (): void => { + state.lineMax = oldLineMax + + // Restore original tShift; this might not be necessary since the parser + // has already been here, but just to make sure we can do that. + for (let i = 0; i < oldTShift.length; i++) { + state.bMarks[i + startLine] = oldBMarks[i] + state.tShift[i + startLine] = oldTShift[i] + state.sCount[i + startLine] = oldSCount[i] + state.bsCount[i + startLine] = oldBSCount[i] + } + } + + // If we didn't find any alert body, so we don't have a valid alert + if (startLine + 1 >= currentLine || !hasBodyContent) { + state.parentType = oldParentType + + // If we are in silent mode, we don't need to restore the state + if (!silent) + restoreState() + + return false + } + + // from now we know that it's going to be a valid alert, + // so no point trying to find the end of it in silent mode + if (silent) + return true + + const oldIndent = state.blkIndent + + state.blkIndent = 0 + + const titleLines: [number, number] = [startLine, startLine + 1] + const contentLines: [number, number] = [startLine + 1, 0] + + const openToken = state.push('alert_open', 'div', 1) + + openToken.markup = type + openToken.attrJoin('class', `markdown-alert markdown-alert-${type}`) + openToken.map = contentLines + + const titleToken = state.push('alert_title', '', 0) + + titleToken.attrJoin('class', `markdown-alert-title`) + titleToken.markup = type + titleToken.content = typeName + titleToken.map = titleLines + titleToken.meta = { type, typeName, content: titleContent } + + state.md.block.tokenize(state, startLine + 1, currentLine) + + const closeToken = state.push('alert_close', 'div', -1) + + closeToken.markup = type + contentLines[1] = state.line + + state.blkIndent = oldIndent + state.parentType = oldParentType + restoreState() + + return true +} + +export const calloutPlugin: PluginWithOptions = ( + md, + { + openRender, + closeRender, + titleRender, + locales = {}, + } = {}, +) => { + md.block.ruler.before( + 'blockquote', + 'alert', + calloutsDef, + { + alt: ['paragraph', 'reference', 'blockquote', 'list'], + }, + ) + + md.renderer.rules.alert_open = openRender + ?? ((tokens, index) => { + const type = tokens[index].markup + const actualType = calloutAlias[type] || type + const tag = actualType === 'details' ? 'details' : 'div' + return `<${tag} class="hint-container ${uniq([actualType, type]).join(' ')}">\n` + }) + + md.renderer.rules.alert_close = closeRender ?? ((tokens, index) => { + const type = tokens[index].markup + const actualType = calloutAlias[type] || type + return `\n` + }) + + md.renderer.rules.alert_title = titleRender + ?? ((tokens, index, _, env: MarkdownEnv): string => { + const { type, content } = tokens[index].meta + const actualType = calloutAlias[type] || type + const tag = actualType === 'details' ? 'summary' : 'p' + const title = content.replace(/^\s*[+-]?/, '').trim() + const rendered = title ? md.renderInline(title, cleanMarkdownEnv(env)) : '' + const relativePath = ensureLeadingSlash(env.filePathRelative ?? '') + const locale = resolveLocalePath(locales, relativePath) + return `<${tag}${tag === 'summary' ? '' : ' class="hint-container-title"'}>${ + rendered || locales[locale]?.[type] || capitalize(type) + }\n` + }) +} diff --git a/plugins/plugin-md-power/src/node/obsidian/embedLink.ts b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts index 200f8497..dc7b6ef8 100644 --- a/plugins/plugin-md-power/src/node/obsidian/embedLink.ts +++ b/plugins/plugin-md-power/src/node/obsidian/embedLink.ts @@ -15,6 +15,9 @@ */ import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' +import type { RuleInline } from 'markdown-it/lib/parser_inline.mjs' +import type StateBlock from 'markdown-it/lib/rules_block/state_block.mjs' +import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs' import type { App } from 'vuepress' import type { Markdown, MarkdownEnv } from 'vuepress/markdown' import { attempt } from '@pengzhanbo/utils' @@ -23,6 +26,7 @@ import Token from 'markdown-it/lib/token.mjs' import { ensureLeadingSlash, isLinkHttp } from 'vuepress/shared' import { fs, hash, path } from 'vuepress/utils' import { checkSupportType, SUPPORTED_VIDEO_TYPES } from '../embed/video/artPlayer.js' +import { resolvePaths } from '../enhance/links.js' import { cleanMarkdownEnv } from '../utils/cleanMarkdownEnv.js' import { parseRect } from '../utils/parseRect.js' import { slugify } from '../utils/slugify.js' @@ -32,13 +36,14 @@ interface EmbedLinkMeta { filename: string hashes: string[] settings: string + isInline: boolean } const EXTENSION_IMAGES: string[] = ['.jpg', '.jpeg', '.png', '.gif', '.avif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.apng', '.jfif', '.pjpeg', '.pjp', '.xbm'] const EXTENSION_AUDIOS: string[] = ['.mp3', '.flac', '.wav', '.ogg', '.opus', '.webm', '.acc'] const EXTENSION_VIDEOS: string[] = SUPPORTED_VIDEO_TYPES.map(ext => `.${ext}`) -const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => { +const blockEmbedLinkDef: RuleBlock = (state, startLine, endLine, silent) => { const start = state.bMarks[startLine] + state.tShift[startLine] const max = state.eMarks[startLine] @@ -71,7 +76,124 @@ const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => { // ![[xxxx]] // ^^^^ <- content const content = line.slice(3, -2).trim() + genEmbedAsset(state, content) + state.line = startLine + 1 + return true +} + +const inlineEmbedLinkDef: RuleInline = (state, silent) => { + let found = false + const max = state.posMax + const start = state.pos + + if ( + state.src.charCodeAt(start) !== 0x21 // \! + || state.src.charCodeAt(start + 1) !== 0x5B // [ + || state.src.charCodeAt(start + 2) !== 0x5B // [ + ) { + return false + } + + /* istanbul ignore if -- @preserve */ + if (silent) + return false + + // - ![[]] + if (max - start < 6) + return false + + state.pos = start + 2 + + // 查找 ]] + while (state.pos < max) { + if (state.src.charCodeAt(state.pos) === 0x5D + && state.src.charCodeAt(state.pos + 1) === 0x5D) { + found = true + break + } + + state.md.inline.skipToken(state) + } + + if (!found || start + 2 === state.pos) { + state.pos = start + return false + } + // [[xxxx]] + // ^^^^ <- content + const content = state.src.slice(start + 3, state.pos).trim() + // found! + state.posMax = state.pos + state.pos = start + 3 + + genEmbedAsset(state, content, true) + + state.pos = state.posMax + 2 + state.posMax = max + + return true +} + +export function embedLinkPlugin(md: Markdown, app: App): void { + md.block.ruler.before( + 'import_code', + 'obsidian_block_embed_link', + blockEmbedLinkDef, + { alt: ['paragraph', 'reference', 'blockquote', 'list'] }, + ) + md.inline.ruler.before('emphasis', 'obsidian_inline_embed_link', inlineEmbedLinkDef) + + md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => { + const token = tokens[idx] + const { filename, hashes, settings, isInline } = token.meta as EmbedLinkMeta + const pagePath = findFirstPage(filename, env.filePathRelative ?? '') + // 行内规则,解析为链接 + if (isInline && pagePath) { + const anchor = hashes.at(-1) + const slug = anchor ? `#${slugify(anchor)}` : '' + const { absolutePath, relativePath } = resolvePaths( + pagePath, + env.base || '/', + env.filePathRelative ?? null, + ) + ;(env.links ??= []).push({ + raw: pagePath, + absolute: absolutePath, + relative: relativePath, + }) + return `${md.utils.escapeHtml(settings) || (hashes.length ? `` : '')}` + } + + // 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面 + if (pagePath) { + const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8')) + if (error) { + console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`) + return '' + } + const { content: rawContent } = grayMatter(markdown) + if (!rawContent) { + console.warn(`[embedLinkPlugin] file ${pagePath} is empty`) + return '' + } + const content = extractContentByHeadings(rawContent, hashes) + pagePath && (env.importedFiles ??= []).push(pagePath) + return md.render(content, cleanMarkdownEnv(env)) + } + + // 其他资源,解析为链接 + const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename) + const anchor = hashes.at(-1) + const slug = anchor ? `#${slugify(anchor)}` : '' + const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : '')) + return `${ + md.utils.escapeHtml(text) + }` + } +} + +function genEmbedAsset(state: StateBlock | StateInline, content: string, isInline = false): void { const [file, settings] = content.split('|').map(x => x.trim()) const [filename, ...hashes] = file.trim().split('#').map(x => x.trim()) const extname = path.extname(filename).toLowerCase() @@ -156,51 +278,10 @@ const embedLinkDef: RuleBlock = (state, startLine, endLine, silent) => { filename: filename.trim(), hashes: hashes.map(hash => hash.trim()), settings: settings?.trim(), + isInline, } as EmbedLinkMeta token.content = content } - - state.line = startLine + 1 - return true -} - -export function embedLinkPlugin(md: Markdown, app: App): void { - md.block.ruler.before( - 'import_code', - 'obsidian_embed_link', - embedLinkDef, - { alt: ['paragraph', 'reference', 'blockquote', 'list'] }, - ) - md.renderer.rules.obsidian_embed_link = (tokens, idx, _, env: MarkdownEnv) => { - const token = tokens[idx] - const { filename, hashes, settings } = token.meta as EmbedLinkMeta - const pagePath = findFirstPage(filename, env.filePathRelative ?? '') - // 解析为内部 markdown 资源,提取 markdown 片段并插入到当前页面 - if (pagePath) { - const [error, markdown] = attempt(() => fs.readFileSync(app.dir.source(pagePath), 'utf-8')) - if (error) { - console.warn(`[embedLinkPlugin] can not read file: ${pagePath}`) - return '' - } - const { content: rawContent } = grayMatter(markdown) - if (!rawContent) { - console.warn(`[embedLinkPlugin] file ${pagePath} is empty`) - return '' - } - const content = extractContentByHeadings(rawContent, hashes) - pagePath && (env.importedFiles ??= []).push(pagePath) - return md.render(content, cleanMarkdownEnv(env)) - } - - // 其他资源,解析为链接 - const url = ensureLeadingSlash(filename[0] === '.' ? path.join(path.dirname(env.filePathRelative ?? ''), filename) : filename) - const anchor = hashes.at(-1) - const slug = anchor ? `#${slugify(anchor)}` : '' - const text = settings || (filename + (hashes.length ? ` > ${hashes.join(' > ')}` : '')) - return `${ - md.utils.escapeHtml(text) - }` - } } function resolveFilenameToAssetPath(filename: string): string { diff --git a/plugins/plugin-md-power/src/node/obsidian/index.ts b/plugins/plugin-md-power/src/node/obsidian/index.ts index 843475f1..6c5eea0f 100644 --- a/plugins/plugin-md-power/src/node/obsidian/index.ts +++ b/plugins/plugin-md-power/src/node/obsidian/index.ts @@ -1,7 +1,10 @@ import type { App } from 'vuepress' import type { Markdown } from 'vuepress/markdown' -import type { MarkdownPowerPluginOptions } from '../../shared/index.js' +import type { MarkdownPowerPluginOptions, MDPowerLocaleData, ObsidianLocaleData } from '../../shared/index.js' +import { deepAssign, type ExactLocaleConfig } from '@vuepress/helper' import { isPlainObject } from 'vuepress/shared' +import { findLocales } from '../utils/findLocales.js' +import { calloutPlugin } from './callouts.js' import { commentPlugin } from './comment.js' import { embedLinkPlugin } from './embedLink.js' import { initPagePaths } from './findFirstPage.js' @@ -13,11 +16,13 @@ export function obsidianPlugin( app: App, md: Markdown, options: MarkdownPowerPluginOptions, + locales: ExactLocaleConfig, ) { if (options.obsidian === false) return const obsidian = isPlainObject(options.obsidian) ? options.obsidian : {} + const obsidianLocales = findLocales(locales, 'obsidian') initPagePaths(app) @@ -29,4 +34,12 @@ export function obsidianPlugin( if (obsidian.comment !== false) commentPlugin(md) + + if (obsidian.callout !== false) { + const { locales = {}, ...options } = isPlainObject(obsidian.callout) ? obsidian.callout : {} + calloutPlugin(md, { + ...options, + locales: deepAssign>({}, obsidianLocales, locales), + }) + } } diff --git a/plugins/plugin-md-power/src/node/plugin.ts b/plugins/plugin-md-power/src/node/plugin.ts index f2d849f7..a72dac6b 100644 --- a/plugins/plugin-md-power/src/node/plugin.ts +++ b/plugins/plugin-md-power/src/node/plugin.ts @@ -113,7 +113,7 @@ export function markdownPowerPlugin( await containerPlugin(app, md, options, locales) await imageSizePlugin(app, md, options.imageSize) - obsidianPlugin(app, md, options) + obsidianPlugin(app, md, options, locales) }, onPrepared: async () => { diff --git a/plugins/plugin-md-power/src/shared/index.ts b/plugins/plugin-md-power/src/shared/index.ts index bf94be0d..f3b43ab0 100644 --- a/plugins/plugin-md-power/src/shared/index.ts +++ b/plugins/plugin-md-power/src/shared/index.ts @@ -9,6 +9,7 @@ export * from './icon.js' export * from './jsfiddle.js' export * from './locale.js' export * from './npmTo.js' +export * from './obsidian.js' export * from './pdf.js' export * from './plot.js' export * from './plugin.js' diff --git a/plugins/plugin-md-power/src/shared/locale.ts b/plugins/plugin-md-power/src/shared/locale.ts index 4c678936..10997bda 100644 --- a/plugins/plugin-md-power/src/shared/locale.ts +++ b/plugins/plugin-md-power/src/shared/locale.ts @@ -1,5 +1,6 @@ import type { LocaleData } from 'vuepress' import type { EncryptSnippetLocale } from './encrypt' +import type { ObsidianLocaleData } from './obsidian' /** * Markdown Power Plugin Locale Data @@ -19,6 +20,12 @@ export interface MDPowerLocaleData extends LocaleData { * 加密片段本地化数据 */ encrypt?: EncryptSnippetLocale + /** + * Obsidian locale data + * + * Obsidian 本地化数据 + */ + obsidian?: ObsidianLocaleData } /** diff --git a/plugins/plugin-md-power/src/shared/obsidian.ts b/plugins/plugin-md-power/src/shared/obsidian.ts index 9b6114ef..570e40fb 100644 --- a/plugins/plugin-md-power/src/shared/obsidian.ts +++ b/plugins/plugin-md-power/src/shared/obsidian.ts @@ -1,5 +1,46 @@ +import type { RenderRule } from 'markdown-it/lib/renderer.mjs' + export interface ObsidianOptions { wikiLink?: boolean embedLink?: boolean comment?: boolean + callout?: boolean | ObsidianCalloutOptions +} + +export interface ObsidianCalloutOptions { + locales?: Record + openRender?: RenderRule + closeRender?: RenderRule + titleRender?: RenderRule +} + +export interface ObsidianLocaleData { + note?: string + quote?: string + cite?: string + tip?: string + hint?: string + info?: string + todo?: string + success?: string + check?: string + done?: string + warning?: string + question?: string + help?: string + faq?: string + caution?: string + attention?: string + failure?: string + fail?: string + missing?: string + danger?: string + error?: string + bug?: string + important?: string + example?: string + details?: string + abstract?: string + summary?: string + tldr?: string } diff --git a/theme/src/client/styles/hint-container.css b/theme/src/client/styles/hint-container.css index 56524c0d..6433d130 100644 --- a/theme/src/client/styles/hint-container.css +++ b/theme/src/client/styles/hint-container.css @@ -1,177 +1,81 @@ -/* stylelint-disable no-descending-specificity */ + +:root { + --vp-hint-container-text: var(--vp-c-text-2); + --vp-hint-container-font-size: var(--vp-custom-block-font-size); + --vp-hint-container-line-height: var(--vp-custom-block-line-height); + --vp-hint-container-border: transparent; + --vp-hint-container-bg: transparent; + --vp-hint-container-link-text: var(--vp-c-brand-1); + --vp-hint-container-link-hover: var(--vp-c-brand-2); + --vp-hint-container-code-text: var(--vp-c-brand-1); + --vp-hint-container-code-bg: var(--vp-code-bg); + --vp-hint-container-icon: none; +} + .vp-doc .hint-container { padding: 16px 16px 8px; margin: 16px auto; - font-size: var(--vp-custom-block-font-size); - line-height: var(--vp-custom-block-line-height); - color: var(--vp-c-text-2); - border: 1px solid transparent; + font-size: var(--vp-hint-container-font-size); + line-height: var(--vp-hint-container-line-height); + color: var(--vp-hint-container-text); + background-color: var(--vp-hint-container-bg); + border: 1px solid; + border-color: var(--vp-hint-container-border); border-radius: 8px; } -.vp-doc .hint-container.info { - color: var(--vp-custom-block-info-text); - background-color: var(--vp-custom-block-info-bg); - border-color: var(--vp-custom-block-info-border); +.vp-doc .hint-container a { + color: var(--vp-hint-container-link-text); } -.vp-doc .hint-container.info a, -.vp-doc .hint-container.info code { - color: var(--vp-c-brand-1); +.vp-doc .hint-container code { + font-size: var(--vp-custom-block-code-font-size); + color: var(--vp-hint-container-code-text); + background-color: var(--vp-hint-container-code-bg); } -.vp-doc .hint-container.info a:hover, -.vp-doc .hint-container.info a:hover > code { - color: var(--vp-c-brand-2); +.vp-doc .hint-container a:hover, +.vp-doc .hint-container a:hover > code { + color: var(--vp-hint-container-link-hover); } -.vp-doc .hint-container.info code { - background-color: var(--vp-custom-block-info-code-bg); -} - -.vp-doc .hint-container.note { - color: var(--vp-custom-block-note-text); - background-color: var(--vp-custom-block-note-bg); - border-color: var(--vp-custom-block-note-border); -} - -.vp-doc .hint-container.note a, -.vp-doc .hint-container.note code { - color: var(--vp-c-brand-1); -} - -.vp-doc .hint-container.note a:hover, -.vp-doc .hint-container.note a:hover > code { - color: var(--vp-c-brand-2); -} - -.vp-doc .hint-container.note code { - background-color: var(--vp-custom-block-note-code-bg); -} - -.vp-doc .hint-container.tip { - color: var(--vp-custom-block-tip-text); - background-color: var(--vp-custom-block-tip-bg); - border-color: var(--vp-custom-block-tip-border); -} - -.vp-doc .hint-container.tip a, -.vp-doc .hint-container.tip code { - color: var(--vp-c-tip-1); -} - -.vp-doc .hint-container.tip a:hover, -.vp-doc .hint-container.tip a:hover > code { - color: var(--vp-c-tip-2); -} - -.vp-doc .hint-container.tip code { - background-color: var(--vp-custom-block-tip-code-bg); -} - -.vp-doc .hint-container.important { - color: var(--vp-custom-block-important-text); - background-color: var(--vp-custom-block-important-bg); - border-color: var(--vp-custom-block-important-border); -} - -.vp-doc .hint-container.important a, -.vp-doc .hint-container.important code { - color: var(--vp-c-important-1); -} - -.vp-doc .hint-container.important a:hover, -.vp-doc .hint-container.important a:hover > code { - color: var(--vp-c-important-2); -} - -.vp-doc .hint-container.important code { - background-color: var(--vp-custom-block-important-code-bg); -} - -.vp-doc .hint-container.warning { - color: var(--vp-custom-block-warning-text); - background-color: var(--vp-custom-block-warning-bg); - border-color: var(--vp-custom-block-warning-border); -} - -.vp-doc .hint-container.warning a, -.vp-doc .hint-container.warning code { - color: var(--vp-c-warning-1); -} - -.vp-doc .hint-container.warning a:hover, -.vp-doc .hint-container.warning a:hover > code { - color: var(--vp-c-warning-2); -} - -.vp-doc .hint-container.warning code { - background-color: var(--vp-custom-block-warning-code-bg); -} - -.vp-doc .hint-container.danger { - color: var(--vp-custom-block-danger-text); - background-color: var(--vp-custom-block-danger-bg); - border-color: var(--vp-custom-block-danger-border); -} - -.vp-doc .hint-container.danger a, -.vp-doc .hint-container.danger code { - color: var(--vp-c-danger-1); -} - -.vp-doc .hint-container.danger a:hover, -.vp-doc .hint-container.danger a:hover > code { - color: var(--vp-c-danger-2); -} - -.vp-doc .hint-container.danger code { - background-color: var(--vp-custom-block-danger-code-bg); -} - -.vp-doc .hint-container.caution { - color: var(--vp-custom-block-caution-text); - background-color: var(--vp-custom-block-caution-bg); - border-color: var(--vp-custom-block-caution-border); -} - -.vp-doc .hint-container.caution a, -.vp-doc .hint-container.caution code { - color: var(--vp-c-caution-1); -} - -.vp-doc .hint-container.caution a:hover, -.vp-doc .hint-container.caution a:hover > code { - color: var(--vp-c-caution-2); -} - -.vp-doc .hint-container.caution code { - background-color: var(--vp-custom-block-caution-code-bg); -} - -.vp-doc .hint-container.details { - color: var(--vp-custom-block-details-text); - background-color: var(--vp-custom-block-details-bg); - border-color: var(--vp-custom-block-details-border); -} - -.vp-doc .hint-container.details a { - color: var(--vp-c-brand-1); -} - -.vp-doc .hint-container.details a:hover, -.vp-doc .hint-container.details a:hover > code { - color: var(--vp-c-brand-2); -} - -.vp-doc .hint-container.details code { - background-color: var(--vp-custom-block-details-code-bg); +.vp-doc .hint-container-title::before { + display: inline-block; + width: 1.25em; + height: 1.25em; + margin-right: 4px; + vertical-align: middle; + content: ""; + background-image: var(--vp-hint-container-icon); + background-repeat: no-repeat; + background-size: 100%; + transform: translateY(-1px); } .vp-doc .hint-container-title { font-weight: 600; } +.vp-doc .hint-container p { + line-height: var(--vp-custom-block-line-height); +} + +.vp-doc .hint-container th, +.vp-doc .hint-container blockquote > p { + font-size: var(--vp-custom-block-font-size); + color: inherit; +} + +.vp-doc .hint-container th, +.vp-doc .hint-container blockquote > p { + font-size: var(--vp-custom-block-font-size); + color: inherit; +} + +.vp-doc .hint-container p + p { + margin: 8px 0; +} + .vp-doc .hint-container p + p { margin: 8px 0; } @@ -191,26 +95,6 @@ opacity: 0.75; } -.vp-doc .hint-container code { - font-size: var(--vp-custom-block-code-font-size); -} - -.vp-doc .hint-container.vp-doc .hint-container th, -.vp-doc .hint-container.vp-doc .hint-container blockquote > p { - font-size: var(--vp-custom-block-font-size); - color: inherit; -} - -/* ---------------------------------------- */ - -.vp-doc .hint-container p { - line-height: var(--vp-custom-block-line-height); -} - -.vp-doc .hint-container p + p { - margin: 8px 0; -} - .vp-doc .hint-container > :not(summary):first-child { margin-top: 0 !important; } @@ -219,12 +103,6 @@ margin-bottom: 8px !important; } -.vp-doc .hint-container th, -.vp-doc .hint-container blockquote > p { - font-size: var(--vp-custom-block-font-size); - color: inherit; -} - .vp-doc .hint-container div[class*="language-"] { margin: 16px 0; } @@ -262,49 +140,134 @@ } } -.vp-doc .hint-container-title::before { - display: inline-block; - width: 1.25em; - height: 1.25em; - margin-right: 4px; - vertical-align: middle; - content: ""; - background-image: var(--icon); - background-repeat: no-repeat; - background-size: 100%; - transform: translateY(-1px); -} - @media print { .vp-doc .hint-container-title::before { display: none; } } -.vp-doc .hint-container.note .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E"); +.vp-doc .hint-container:where(.info,.todo) { + --vp-hint-container-text: var(--vp-custom-block-info-text); + --vp-hint-container-bg: var(--vp-custom-block-info-bg); + --vp-hint-container-border: var(--vp-custom-block-info-border); + --vp-hint-container-code-bg: var(--vp-custom-block-info-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E"); } -.vp-doc .hint-container.info .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 48 48'%3E%3Ccircle cx='24' cy='24' r='21' fill='%232196f3'/%3E%3Cpath fill='%23fff' d='M22 22h4v11h-4z'/%3E%3Ccircle cx='24' cy='16.5' r='2.5' fill='%23fff'/%3E%3C/svg%3E"); +.vp-doc .hint-container:where(.note,.quote,.cite) { + --vp-hint-container-text: var(--vp-custom-block-note-text); + --vp-hint-container-bg: var(--vp-custom-block-note-bg); + --vp-hint-container-border: var(--vp-custom-block-note-border); + --vp-hint-container-link-text: var(--vp-c-brand-1); + --vp-hint-container-link-hover: var(--vp-c-brand-2); + --vp-hint-container-code-text: var(--vp-c-brand-1); + --vp-hint-container-code-bg: var(--vp-custom-block-note-code-bg); } -.vp-doc .hint-container.tip .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E"); +.vp-doc .hint-container:where(.tip,.hint) { + --vp-hint-container-text: var(--vp-custom-block-tip-text); + --vp-hint-container-bg: var(--vp-custom-block-tip-bg); + --vp-hint-container-border: var(--vp-custom-block-tip-border); + --vp-hint-container-link-text: var(--vp-c-tip-1); + --vp-hint-container-link-hover: var(--vp-c-tip-2); + --vp-hint-container-code-text: var(--vp-c-tip-1); + --vp-hint-container-code-bg: var(--vp-custom-block-tip-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 512 512'%3E%3Cpath fill='%2330a46c' d='M208 464h96v32h-96zm-16-48h128v32H192zM369.42 62.69C339.35 32.58 299.07 16 256 16A159.62 159.62 0 0 0 96 176c0 46.62 17.87 90.23 49 119.64l4.36 4.09C167.37 316.57 192 339.64 192 360v40h48V269.11L195.72 244L214 217.72L256 240l41.29-22.39l19.1 25.68l-44.39 26V400h48v-40c0-19.88 24.36-42.93 42.15-59.77l4.91-4.66C399.08 265 416 223.61 416 176a159.16 159.16 0 0 0-46.58-113.31'/%3E%3C/svg%3E"); } -.vp-doc .hint-container.warning .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E"); +.vp-doc .hint-container:where(.important,.example) { + --vp-hint-container-text: var(--vp-custom-block-important-text); + --vp-hint-container-bg: var(--vp-custom-block-important-bg); + --vp-hint-container-border: var(--vp-custom-block-important-border); + --vp-hint-container-link-text: var(--vp-c-important-1); + --vp-hint-container-link-hover: var(--vp-c-important-2); + --vp-hint-container-code-text: var(--vp-c-important-1); + --vp-hint-container-code-bg: var(--vp-custom-block-important-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E"); } -.vp-doc .hint-container.danger .hint-container-title::before, -.vp-doc .hint-container.caution .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E"); - - width: 1.4em; - height: 1.4em; +.vp-doc .hint-container:where(.success,.check,.done) { + --vp-hint-container-text: var(--vp-custom-block-success-text); + --vp-hint-container-bg: var(--vp-custom-block-success-bg); + --vp-hint-container-border: var(--vp-custom-block-success-border); + --vp-hint-container-link-text: var(--vp-c-success-1); + --vp-hint-container-link-hover: var(--vp-c-success-2); + --vp-hint-container-code-text: var(--vp-c-success-1); + --vp-hint-container-code-bg: var(--vp-custom-block-success-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%2318794e' d='M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z'/%3E%3C/svg%3E"); } -.vp-doc .hint-container.important .hint-container-title::before { - --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%238e5cd9' d='M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m0-9q.425 0 .713-.288T13 12V9q0-.425-.288-.712T12 8t-.712.288T11 9v3q0 .425.288.713T12 13m0 3q.425 0 .713-.288T13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16'/%3E%3C/svg%3E"); +.vp-doc .hint-container:where(.warning,.question,.help,.faq) { + --vp-hint-container-text: var(--vp-custom-block-warning-text); + --vp-hint-container-bg: var(--vp-custom-block-warning-bg); + --vp-hint-container-border: var(--vp-custom-block-warning-border); + --vp-hint-container-link-text: var(--vp-c-warning-1); + --vp-hint-container-link-hover: var(--vp-c-warning-2); + --vp-hint-container-code-text: var(--vp-c-warning-1); + --vp-hint-container-code-bg: var(--vp-custom-block-warning-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 16 16'%3E%3Cpath fill='%23da8b17' fill-rule='evenodd' d='M6.285 1.975C7.06.68 8.939.68 9.715 1.975l5.993 9.997c.799 1.333-.161 3.028-1.716 3.028H2.008C.453 15-.507 13.305.292 11.972zM8 5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5m1 6.5a1 1 0 1 1-2 0a1 1 0 0 1 2 0' clip-rule='evenodd'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.danger,.caution,.attention,.failure,.fail,.missing,.bug,.error) { + --vp-hint-container-text: var(--vp-custom-block-danger-text); + --vp-hint-container-bg: var(--vp-custom-block-danger-bg); + --vp-hint-container-border: var(--vp-custom-block-danger-border); + --vp-hint-container-link-text: var(--vp-c-danger-1); + --vp-hint-container-link-hover: var(--vp-c-danger-2); + --vp-hint-container-code-text: var(--vp-c-danger-1); + --vp-hint-container-code-bg: var(--vp-custom-block-danger-code-bg); + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23b62a3c' d='M8.27 3L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3M8.41 7L12 10.59L15.59 7L17 8.41L13.41 12L17 15.59L15.59 17L12 13.41L8.41 17L7 15.59L10.59 12L7 8.41'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.caution) { + --vp-hint-container-text: var(--vp-custom-block-caution-text); + --vp-hint-container-bg: var(--vp-custom-block-caution-bg); + --vp-hint-container-border: var(--vp-custom-block-caution-border); + --vp-hint-container-link-text: var(--vp-c-caution-1); + --vp-hint-container-link-hover: var(--vp-c-caution-2); + --vp-hint-container-code-text: var(--vp-c-caution-1); + --vp-hint-container-code-bg: var(--vp-custom-block-caution-code-bg); +} + +.vp-doc .hint-container:where(.details,.abstract,.summary,.tldr) { + --vp-hint-container-text: var(--vp-custom-block-details-text); + --vp-hint-container-bg: var(--vp-custom-block-details-bg); + --vp-hint-container-border: var(--vp-custom-block-details-border); + --vp-hint-container-code-bg: var(--vp-custom-block-details-code-bg); +} + +.vp-doc .hint-container:where(.note) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%235da1a2' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10 4H7.2c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C4 5.52 4 6.08 4 7.2v9.6c0 1.12 0 1.68.218 2.108a2 2 0 0 0 .874.874c.427.218.987.218 2.105.218h9.606c1.118 0 1.677 0 2.104-.218c.377-.192.683-.498.875-.874c.218-.428.218-.987.218-2.105V14m-4-9l-6 6v3h3l6-6m-3-3l3-3l3 3l-3 3m-3-3l3 3'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.quote,.cite) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%235da1a2' d='M9 22c-.6 0-1-.4-1-1v-3H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2h-6.1l-3.7 3.7c-.2.2-.4.3-.7.3zm1-6v3.1l3.1-3.1H20V4H4v12zm6.3-10l-1.4 3H17v4h-4V8.8L14.3 6zm-6 0L8.9 9H11v4H7V8.8L8.3 6z'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.hint) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23299764' stroke-linejoin='round' stroke-width='2' d='M15 19c1.2-3.678 2.526-5.005 6-6c-3.474-.995-4.8-2.322-6-6c-1.2 3.678-2.526 5.005-6 6c3.474.995 4.8 2.322 6 6Zm-8-9c.6-1.84 1.263-2.503 3-3c-1.737-.497-2.4-1.16-3-3c-.6 1.84-1.263 2.503-3 3c1.737.497 2.4 1.16 3 3Zm1.5 10c.3-.92.631-1.251 1.5-1.5c-.869-.249-1.2-.58-1.5-1.5c-.3.92-.631 1.251-1.5 1.5c.869.249 1.2.58 1.5 1.5Z'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.todo) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 2048 2048'%3E%3Cpath fill='%232196f3' d='M768 256h1280v128H768zm0 768V896h1280v128zm0 640v-128h1280v128zM256 768q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12m0 240q53 0 99 20t82 55t55 81t20 100q0 53-20 99t-55 82t-81 55t-100 20q-53 0-99-20t-82-55t-55-81t-20-100q0-53 20-99t55-82t81-55t100-20m0 400q30 0 56-11t45-31t31-46t12-56t-11-56t-31-45t-46-31t-56-12t-56 11t-45 31t-31 46t-12 56t11 56t31 45t46 31t56 12M192 358L467 83l90 90l-365 365L19 365l90-90z'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.question,.help) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cg fill='none'%3E%3Cpath d='m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z'/%3E%3Cpath fill='%23946300' d='M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2m0 14a1 1 0 1 0 0 2a1 1 0 0 0 0-2m0-9.5a3.625 3.625 0 0 0-3.625 3.625a1 1 0 1 0 2 0a1.625 1.625 0 1 1 2.23 1.51c-.676.27-1.605.962-1.605 2.115V14a1 1 0 1 0 2 0c0-.244.05-.366.261-.47l.087-.04A3.626 3.626 0 0 0 12 6.5'/%3E%3C/g%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.faq) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23946300' d='M18.05 6.56a.84.84 0 0 0-.84.84v4.78a.84.84 0 1 0 1.68 0V7.4a.85.85 0 0 0-.84-.84m-6.37 0a.85.85 0 0 0-.84.84v2.43h1.68V7.4a.85.85 0 0 0-.84-.84'/%3E%3Cpath fill='%23946300' d='M22.88 1.94H1.12A1.12 1.12 0 0 0 0 3.06v14.5a1.12 1.12 0 0 0 1.12 1.13h2.6a8.3 8.3 0 0 1-1.48 3.37c3.26.05 5.32-1.21 6.55-3.37h14.09A1.12 1.12 0 0 0 24 17.56V3.06a1.12 1.12 0 0 0-1.12-1.12M7.54 6.56H6a.85.85 0 0 0-.84.84v2.43h1.59a.75.75 0 0 1 0 1.5H5.11v2.44a.75.75 0 1 1-1.5 0V7.4A2.34 2.34 0 0 1 6 5.06h1.54a.75.75 0 1 1 0 1.5M14 13.77a.75.75 0 0 1-1.5 0v-2.44h-1.66v2.44a.75.75 0 0 1-1.5 0V7.4a2.34 2.34 0 1 1 4.66 0Zm6.37-1.59a2.37 2.37 0 0 1-1 1.9l.75.75a.75.75 0 0 1 0 1.06a.75.75 0 0 1-1.06 0l-1.4-1.4a2.34 2.34 0 0 1-2-2.31V7.4a2.34 2.34 0 1 1 4.68 0Z'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.example) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Ccircle cx='4' cy='7' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='12' r='1' fill='%237e4cc9'/%3E%3Ccircle cx='4' cy='17' r='1' fill='%237e4cc9'/%3E%3Crect width='14' height='2' x='7' y='11' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='16' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3Crect width='14' height='2' x='7' y='6' fill='%237e4cc9' rx='.94' ry='.94'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.attention) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 48 48'%3E%3Cdefs%3E%3Cmask id='SVGkYaseeMx'%3E%3Cg fill='none'%3E%3Cpath fill='%23fff' stroke='%23fff' stroke-linejoin='round' stroke-width='4' d='M24 44a19.94 19.94 0 0 0 14.142-5.858A19.94 19.94 0 0 0 44 24a19.94 19.94 0 0 0-5.858-14.142A19.94 19.94 0 0 0 24 4A19.94 19.94 0 0 0 9.858 9.858A19.94 19.94 0 0 0 4 24a19.94 19.94 0 0 0 5.858 14.142A19.94 19.94 0 0 0 24 44Z'/%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M24 37a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='4' d='M24 12v16'/%3E%3C/g%3E%3C/mask%3E%3C/defs%3E%3Cpath fill='%23d5393e' d='M0 0h48v48H0z' mask='url(%23SVGkYaseeMx)'/%3E%3C/svg%3E"); +} + +.vp-doc .hint-container:where(.bug) { + --vp-hint-container-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'%3E%3Cpath fill='%23d5393e' d='m21.55 9.11l-1.84.92v-.02H4.29v.02l-1.84-.92l-.89 1.79l2.47 1.23c0 .12-.02.24-.02.37c0 .51.04 1.01.11 1.5H2.01v2h2.57c.17.5.37.97.6 1.42l-1.87 1.87l1.41 1.41l1.58-1.58c1.23 1.5 2.87 2.52 4.71 2.79v-8.92h2v8.92c1.84-.27 3.48-1.3 4.71-2.79l1.58 1.58l1.41-1.41l-1.87-1.87c.23-.45.43-.93.6-1.42h2.57v-2H19.9c.07-.49.11-.99.11-1.5c0-.13-.02-.25-.02-.37l2.47-1.23l-.89-1.79ZM4.96 8h14.09c-.37-.82-.86-1.56-1.41-2.22l2.07-2.07L18.3 2.3l-2.11 2.11C14.97 3.52 13.54 3 12.01 3s-2.96.52-4.18 1.41L5.72 2.3L4.31 3.71l2.07 2.07c-.55.66-1.04 1.4-1.41 2.22Z'/%3E%3C/svg%3E"); } diff --git a/theme/src/client/styles/vars.css b/theme/src/client/styles/vars.css index ea26a5f4..97e4b5b3 100644 --- a/theme/src/client/styles/vars.css +++ b/theme/src/client/styles/vars.css @@ -442,6 +442,11 @@ --vp-custom-block-important-bg: var(--vp-c-important-soft); --vp-custom-block-important-code-bg: var(--vp-c-important-soft); + --vp-custom-block-success-border: transparent; + --vp-custom-block-success-text: var(--vp-c-text-1); + --vp-custom-block-success-bg: var(--vp-c-success-soft); + --vp-custom-block-success-code-bg: var(--vp-c-success-soft); + --vp-custom-block-warning-border: transparent; --vp-custom-block-warning-text: var(--vp-c-text-1); --vp-custom-block-warning-bg: var(--vp-c-warning-soft); diff --git a/theme/src/node/plugins/markdown.ts b/theme/src/node/plugins/markdown.ts index 190cdcf4..df21c42c 100644 --- a/theme/src/node/plugins/markdown.ts +++ b/theme/src/node/plugins/markdown.ts @@ -25,10 +25,11 @@ export function markdownPlugins(pluginOptions: ThemeBuiltinPlugins): PluginConfi const options = getThemeConfig() const plugins: PluginConfig = [] let { hint, image, include, math, mdChart, mdPower } = splitMarkdownOptions(options.markdown ?? {}) - + const obsidian = isPlainObject(mdPower.obsidian) ? mdPower.obsidian : {} plugins.push(markdownHintPlugin({ hint: hint.hint ?? true, - alert: hint.alert ?? true, + // 如果启用了 obsidian 兼容,则禁用 hint.alert,obsidian callout 已处理 alert + alert: !mdPower.obsidian ? (hint.alert ?? true) : obsidian.callout === false, injectStyles: false, }))