CUBERWR/BLOG

Obsidian兼容hugo的解决方案支持实时网页预览不修改本地markdown源文件

2023.07.24

起因

之前选择hugo作为博客的页面生成器,用obsidian来编辑,体验不错,但是也有些问题,比如在obsidian插件shell commands实现hugo博客编辑实时预览这篇文章中实现的效果中,obsidian的双链[[]]不受支持,预览时候不会转换成链接,就只显示原来的文本

我想要实现的效果:

  • 实时预览时候obsidian的双链可以转换成指向文章的链接,同时原本的markdown文件不被修改,方便继续在obsidian内编辑
  • 在部署时候,obsidian的双链可以转换成指向文章的链接,在cf的部署服务上执行,这里由于markdown文件不在本地了,可以先修改markdown原文件再用hugo生成页面

过程记录

首先想到的是hugo会不会有预处理markdown的功能,或者插件能实现,但是搜了一圈资料,无解,而且是老早就有人提过的问题,一直没有很好的解决

之后想通过修改hugo源码,在渲染页面之前对读取的markdown字符串加一个预处理,为此翻了半天hugo源码,改出了第一版的满足我要求的hugo,但是这里还是有些问题的,首先我对hugo的源码并没有全面的了解,会不会引入新bug不可知,其次在cloudflare的页面生成环境里面又要拉一次我的改版hugo,很麻烦,并且之后hugo每次更新我都要重新拉下来检查修改编译,很麻烦

我没找到obsidian的双链信息存储的位置,markdown内也只有[[文章名]],并且只有在不同路径有同名文章时候才需要指定路径,猜测是把所有文章的标题用来匹配,而没有存储链接关系,理论上是可行的,所以后面把双链转换成网页的链接也从这个思路出发,先获取所有文章名和对应的地址,再根据[[文章名]]进行匹配

现在的解决方案是:

注意并不支持标题里有特殊符号,这里只对空格进行了处理,实际因为没去仔细研究hugo的转换规则只做简单处理,如果未来有需要会去看hugo源码的转换规则修改油猴脚本和convert转换程序

本地实时预览时候,在油猴脚本加一段代码,实时修改html实现双链文本转链接,具体实现如下:

// ==UserScript==
// @name         obsidian link convert
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  try to take over the world!
// @author       You
// @match        http://localhost:5678/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=undefined.localhost
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    document.addEventListener('DOMContentLoaded',() => {
        async function getSitemapLinks(sitemapUrl) {
            const response = await fetch(sitemapUrl);
            const data = await response.text();
            const parser = new DOMParser();
            const doc = parser.parseFromString(data, 'application/xml');
            const urls = Array.from(doc.querySelectorAll('url loc')).map(loc => loc.textContent);
            return urls;
        }
        (async () => {
            const sitemapUrl = 'http://localhost:5678/sitemap.xml';
            const links = await getSitemapLinks(sitemapUrl);
            let pages=links.map(e=>decodeURI(e.split('/').at(-2)))
            const map = pages.reduce((acc, key, index) => {
                acc.set(key, links[index]);
                return acc;
            }, new Map());

            function replaceBracketsWithLinks(element = document.body) {
                const regex = /\[\[(.*?)\]\]/g;
                element.childNodes.forEach(node => {
                    if (node.nodeType === Node.TEXT_NODE && regex.test(node.textContent)) {
                        const span = document.createElement('span');
                        span.innerHTML = node.textContent.replace(regex, (match, text) => {
                            const urlText = text.replace(/ /g, "-");
                            const link = map.get(urlText);
                            return `<a href="${link}">${text}</a>`;
                        });
                        node.parentNode.replaceChild(span, node);
                    } else if (node.nodeType === Node.ELEMENT_NODE && node.nodeName !== 'CODE' && node.nodeName !== 'PRE') {
                        replaceBracketsWithLinks(node);
                    }
                });
            }
            replaceBracketsWithLinks()
        })();
    })
})();

主要的逻辑是:获取sitemap里面的所有链接,提取最后两个/之间的文本作为文章名,再把页面中的所有[[文章名]]替换成a标签,链接指向之前从sitemap获取的该名称对应的链接

实际效果是可以完成我的需求的,修改效果没问题,但是还是有些问题,在开启MathJax时候会把[[]]转换成数学公式,我一般是把MathJax关掉的,所以影响不大,如果你有需求,还是要自己探索

cloudflare部署时候,改一下发布执行的命令,在前面加个预处理程序,把所有markdown内的双链都修改成hugo支持的链接,我写了一个go程序来实现:

package main

import (
    "fmt"
    "io/fs"
    "io/ioutil"
    "os"
    "path/filepath"
    "regexp"
    "strings"
)

func processFileContent(content string, names []string, paths []string) string {
    re := regexp.MustCompile(`\[\[(.*?)\]\]`)
    return re.ReplaceAllStringFunc(content, func(match string) string {
        name := match[2 : len(match)-2]
        index := indexOf(names, name)
        if index >= 0 {
            path := paths[index]
            path = strings.ReplaceAll(path, "\\", "/")
            path = strings.TrimSuffix(path, ".md")
            contentIndex := strings.Index(path, "content")
            if contentIndex >= 0 {
                path = path[contentIndex+len("content"):]
            }
            path = strings.ReplaceAll(path, " ", "-")
            return fmt.Sprintf("[%s](%s)", name, path)
        }
        return match
    })
}

func indexOf(slice []string, value string) int {
    for i, v := range slice {
        if v == value {
            return i
        }
    }
    return -1
}

func main() {
    var paths []string
    var names []string

    filepath.WalkDir("content", func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") {
            paths = append(paths, path)
            names = append(names, strings.TrimSuffix(d.Name(), ".md"))
        }
        return nil
    })

    for _, path := range paths {
        contentBytes, err := ioutil.ReadFile(path)
        if err != nil {
            panic(err)
        }
        content := string(contentBytes)
        newContent := processFileContent(content, names, paths)
        err = ioutil.WriteFile(path, []byte(newContent), os.ModePerm)
        if err != nil {
            panic(err)
        }
    }
}

主要逻辑是:遍历content文件夹,获取所有的markdown文件的名称和路径,再按照名称和路径的映射关系,把所有markdown内的[[文章名]]替换成匹配到的文章的hugo可以识别的链接

编译一份linux的可执行文件convert,放到博客的的根目录,再用git上传到github,cloudflare拉取仓库时候就可以获取到转换用的程序,把发布命令修改成chmod 755 convert && ./convert && hugo,就能先执行转换程序再使用hugo发布