Skip to content

7. obsidian支持优化:链接语法

ob链接语法特点

ob主要使用[[ ]]链接其他文档,我考虑到了下面几种情况:

  • [[#xx]] 链接本文档的锚点
  • [[文档名字]][[文档名字.md]] 不一定是md文件。 这里我只考虑md文件。
  • [[某文档#xx]] 链接到某文档的锚点
  • 支持cmd+鼠标悬浮的预览功能
  • [[ 文档 ]] 支持空格
  • 文件名不能包含 \/:
  • 文件名包含 ^|#[]时,链接失效
    • ^ 可控制具体跳转某一个位置
    • | 用于起别名
    • # 用于锚点跳转

1. 支持ob链接的改造

实现思路

vitepress中markdown由 markdown-it驱动,可以通过修改.vitepress/config.ts文件中的markdown属性来支持markdown扩展。因此我只需要编写markdown-it插件即可。

  • 将满足条件的字符串替换为html标签,可以通过markdown-it修改原有的 renderer.rules.text规则来实现
  • 根据文件名匹配文件,并生成对应的路径地址

相关代码实现

定义一个全局变量存储文档地址

js
export const customConfiguration = {  
    docsFolder: 'docs'  
}
export const customConfiguration = {  
    docsFolder: 'docs'  
}

编写插件:

js
// @ts-ignore  
import glob from 'glob';  
// @ts-ignore  
import {sep} from 'path';  
import {customConfiguration} from "../../config";  

const LOCAL_FILE_MAPPING = {};  
  
/**  
 * 根据文件名获取所在目录, 可能还需要考虑 1.项目部署路径不是/的问题  2.docs指定未其他目录的问题  
 * @param fileName  
 */  
const findFilePath = (fileName) => {  
    if (Object.keys(LOCAL_FILE_MAPPING).length === 0) {  
        const fileList = glob.sync(`${customConfiguration.docsFolder}/**/*.md`);  
        for (const mdPath of fileList) {  
            // for fileName  
            const docsFileName = mdPath.split(sep).pop();  
            LOCAL_FILE_MAPPING[docsFileName] = mdPath;  
            LOCAL_FILE_MAPPING[docsFileName.replace(/\.md$/, '')] = mdPath;  
            // for path  
            const relativePath = mdPath.replace(new RegExp(`^${customConfiguration.docsFolder}/`), '')  
            LOCAL_FILE_MAPPING[relativePath] = mdPath;  
            LOCAL_FILE_MAPPING[relativePath.replace(/\.md$/, '')] = mdPath;  
        }  
    }  
    // obsidian支持通过| # ^, 添加额外信息,排除这些字符的影响  
    const splitFileName = fileName.split(/[|#^]/)[0]  
    // obsidian兼容了 / 开头的地址,这里去除一下  
    fileName = splitFileName.replace(/^\//, '');  
    const searchPath = LOCAL_FILE_MAPPING[fileName]  
    if (searchPath) {  
        // 路径不携带文档根目录名  
        const reg = new RegExp(`^${customConfiguration.docsFolder}`)  
        return searchPath.replace(reg, '').replace(/\.md$/, '.html')  
    }  
    return undefined;  
}  
  
export const markdownItObLink = (md: any) => {  
    const text = md.renderer.rules.text  
    md.renderer.rules.text = (...args) => {  
        let rawCode = text(...args);  
        const regex = /\[\[[^\[\]\\:]+?]]/g  
        const matches = rawCode.match(regex);  
        if (!matches || matches.length === 0) {  
            return rawCode;  
        }  
        for (const match of matches) {  
            let fileName = match.replace(/^\[\[/, '')  
                .replace(/]]$/, '').trim();  
            // 在ob中 `|` 用于起别名。另外和ob保持一致: [[a|b|c]] 显示别名为 bc            if (fileName.includes('|')) {  
                fileName = fileName.substring(fileName.indexOf('|')).replace(/\|/g, '')  
            }  
            let mappingPath = findFilePath(fileName);  
            const anchorText = fileName.includes('#') ? `#${fileName.split('#')[1]}` : '';  
            if (mappingPath) {  
                // 在ob中 `#` 用于支持锚点  
                rawCode = rawCode.replace(match, `<span class="relative group inline-block"><a href="${mappingPath}${anchorText}" class="x-ob-link" rel="noopener">${fileName}</a></span>`);  
            } else {  
                // 页面不存在,也同样进行渲染。默认页面位置于根目录  
                rawCode = rawCode.replace(match, `<span class="relative group inline-block"><a href="${fileName}.html${anchorText}" class="x-ob-link opacity-60" rel="noopener">${fileName}</a></span>`);  
            }  
        }  
        return rawCode;  
    }  
}
// @ts-ignore  
import glob from 'glob';  
// @ts-ignore  
import {sep} from 'path';  
import {customConfiguration} from "../../config";  

const LOCAL_FILE_MAPPING = {};  
  
/**  
 * 根据文件名获取所在目录, 可能还需要考虑 1.项目部署路径不是/的问题  2.docs指定未其他目录的问题  
 * @param fileName  
 */  
const findFilePath = (fileName) => {  
    if (Object.keys(LOCAL_FILE_MAPPING).length === 0) {  
        const fileList = glob.sync(`${customConfiguration.docsFolder}/**/*.md`);  
        for (const mdPath of fileList) {  
            // for fileName  
            const docsFileName = mdPath.split(sep).pop();  
            LOCAL_FILE_MAPPING[docsFileName] = mdPath;  
            LOCAL_FILE_MAPPING[docsFileName.replace(/\.md$/, '')] = mdPath;  
            // for path  
            const relativePath = mdPath.replace(new RegExp(`^${customConfiguration.docsFolder}/`), '')  
            LOCAL_FILE_MAPPING[relativePath] = mdPath;  
            LOCAL_FILE_MAPPING[relativePath.replace(/\.md$/, '')] = mdPath;  
        }  
    }  
    // obsidian支持通过| # ^, 添加额外信息,排除这些字符的影响  
    const splitFileName = fileName.split(/[|#^]/)[0]  
    // obsidian兼容了 / 开头的地址,这里去除一下  
    fileName = splitFileName.replace(/^\//, '');  
    const searchPath = LOCAL_FILE_MAPPING[fileName]  
    if (searchPath) {  
        // 路径不携带文档根目录名  
        const reg = new RegExp(`^${customConfiguration.docsFolder}`)  
        return searchPath.replace(reg, '').replace(/\.md$/, '.html')  
    }  
    return undefined;  
}  
  
export const markdownItObLink = (md: any) => {  
    const text = md.renderer.rules.text  
    md.renderer.rules.text = (...args) => {  
        let rawCode = text(...args);  
        const regex = /\[\[[^\[\]\\:]+?]]/g  
        const matches = rawCode.match(regex);  
        if (!matches || matches.length === 0) {  
            return rawCode;  
        }  
        for (const match of matches) {  
            let fileName = match.replace(/^\[\[/, '')  
                .replace(/]]$/, '').trim();  
            // 在ob中 `|` 用于起别名。另外和ob保持一致: [[a|b|c]] 显示别名为 bc            if (fileName.includes('|')) {  
                fileName = fileName.substring(fileName.indexOf('|')).replace(/\|/g, '')  
            }  
            let mappingPath = findFilePath(fileName);  
            const anchorText = fileName.includes('#') ? `#${fileName.split('#')[1]}` : '';  
            if (mappingPath) {  
                // 在ob中 `#` 用于支持锚点  
                rawCode = rawCode.replace(match, `<span class="relative group inline-block"><a href="${mappingPath}${anchorText}" class="x-ob-link" rel="noopener">${fileName}</a></span>`);  
            } else {  
                // 页面不存在,也同样进行渲染。默认页面位置于根目录  
                rawCode = rawCode.replace(match, `<span class="relative group inline-block"><a href="${fileName}.html${anchorText}" class="x-ob-link opacity-60" rel="noopener">${fileName}</a></span>`);  
            }  
        }  
        return rawCode;  
    }  
}

最后在配置中引入即可:

js
import {markdownItObLink} from "./extension/markdown/markdown-it-ob-link";

export default defineConfig({
	...
	markdown: {  	    
	    config: (md) => {  
	        md.use(markdownItObLink)  
	    }  
	}
})
import {markdownItObLink} from "./extension/markdown/markdown-it-ob-link";

export default defineConfig({
	...
	markdown: {  	    
	    config: (md) => {  
	        md.use(markdownItObLink)  
	    }  
	}
})

2. 如何使鼠标悬浮时展示预览弹窗

obsidian的部署的官方主题是支持链接悬浮时,展示预览弹窗的。我们要实现,差不多有三种思路:

  • 方案1:鼠标悬浮时,根据当前的链接获取iframe
    • iframe页面需要将顶部、侧栏都隐藏掉
    • 缺点:加载很多不必要的资源
  • 方案2:鼠标悬浮时,根据当前的链接动态请求md文件,再将md文件内容转换成html
    • 开发时需要调整主题,将md源文件存放到某个固定目录中,例如assets目录中。 可用vite-plugin-static-copy插件解决。然后鼠标悬浮时,动态请求md字符串,再次渲染md成html。
    • 找了下文档,似乎没找到vitepress动态解析md字符串的功能,目前采用单独引用独立的markdown-it渲染。 能展示个大概即可(舍去代码高亮等功能)
    • 缺点: 在浏览器环境使用独立的markdown-it渲染。展现效果和非弹窗有差异
    • 动态加载生成html文件,进行渲染
  • 方案3:动态生成图片
    • 应该非常影响打包效率,且无法复制,不做做考虑

方案2 实现基本说明

(1) 引入依赖
- dependencies引入markdown-it
- devDependencies引入viteStaticCopy
(2) 利用viteStaticCopy的能力将md原始文件进行复制,例如复制到dist/asset文件,便于后面获取
(3) 监听ob链接的class,并动态匹配到md文件的实际路径,请求到数据后,再动态解析markdown,并呈现在弹窗上。二次弹窗可使用 vite-plugin-generate-file插件生成映射文件完成