我大概在今年年初的时候,把所有以前分散在各个平台上的文章、知识库个人帖子,以及老的通过 Emacs Org Mode 管理的内容,全部迁移到了 Obsidian 下,一直有在想,有没有比较好的办法把博客上的文章也放到一起做汇总,这样也有写新文章的动力。后来经过了一段时间的尝试,算是做了一个最简单的无附件(图片之类的)的文章的管理方案,目前这个博客上的所有文章都是从我的 Obsidian 库中直接提取出来发布的。本文章抛砖引玉,介绍一个使用 Obsidian 进行博文编写、管理的解决方案。
额外说明
目前没有实现图片、附件的解析、拆解和同步功能,但是理论上这块的内容是可以通过 tokenizer 来实现的,可以在后面考虑进来,这个文章只是做了抛砖引玉,倒是可以提一提解决方案,但是不会考虑说明具体的实现。
主要流程
目前我的文章是存放在一个 Obsidian 库中,我们这里假设它是 obsidian-vault
目录,我们需要编写一个程序(或者脚本),遍历所有库中的文章,根据 Markdown 的元数据来过滤出需要发布的文章,然后收集到 Hexo 的仓库中,这里我们认为收集到的目的地是 hexo-blog/source/_posts/obsidian
。
元数据的定义如下:
1 2 3
| id: 7b89a1b1-b4f6-442a-8002-32e046f525e0 date: 2023-11-05 13:56 categories: development
|
这里各个字段的含义如下:
键值 |
类型 |
含义 |
id |
字符串 |
文章的 ID |
date |
日期 |
文章创建的时间,在博客中只会显示它的日期 |
categories |
类别 |
文章的类别 |
而在 Obsidian 中,我们可能有自己额外的元数据,并且有发布的标记,我们需要对这些元数据进行过滤、映射,来满足我们发布的要求。为了方便处理,我在 Obsidian 的文件头,添加了一个叫 published
的选项,当它是 true
的时候,它将被脚本处理,过滤完发布到对应的仓库中。
总结一下,整个流水线的流程是:通过 Obsidian 编写文章,打上 published
标记,然后通过脚本进行处理,映射、过滤以及清洗元数据,并处理文章的标签,最后把处理好的文件存放在 hexo-blog
对应的目标文件夹中。
处理脚本的实现
我选择 rust 作为脚本的实现语言,这个其实无所谓,甚至其它的语言有更好的 Markdown 文件元数据处理框架,我没有去折腾了,这里怎么简单怎么来。
提示
如果说要考虑未来图片及相关附件的复制,以及解析,选择一个成熟的 Markdown 解析器是非常重要的。
Markdown 文件结构拆分
首先我们要做的事是把 Markdown 文件的元数据和内容拆分区分出来,在处理 Markdown 文件过程中,我们使用一个 Phase
枚举,来表示我们处理到了哪个阶段:
1 2 3 4 5 6
| #[derive(PartialEq)] enum Phase { START, METADATA, CONTENT, }
|
为了简化问题,我们认为 Markdown 文件一定是由元数据、正文的形式组成,并且元数据一定是由 ---
分隔。如果文件的第一行不是 ---
,我们认为它没有元数据。假设我们已经读取到了文件的原始内容,我们记它为 raw
,以下的代码片断对整个文件进行了解析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| let mut phase = Phase::START;
let mut metadata = String::new(); let mut content = String::new();
for line in raw.lines() { if phase == Phase::CONTENT { content += &format!("{line}\n"); continue; } else if line.trim() == "---" { match phase { Phase::START => phase = Phase::METADATA, Phase::METADATA => { phase = Phase::CONTENT; migrate_metadata(metadata) } _ => {} } continue; } else if phase == Phase::START { return None; }
metadata += &format!("{line}\n"); }
|
在上面的代码片段中的第 21 行,如果文章一开始不是 ---
,说明它没有元数据,而没有元数据的文章我们认为是不会用来发布的(没有 published
元数据),故这里我们直接返回,直接跳过这个文件即可。
Markdown 文件元数据的处理
接起来我们需要对元数据处理,包括键值的映射、以及特定的一些元数据值的获取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| let metadata_mappings = HashMap::from([ ("id", "id"), ("date_created", "date"), ("categories", "categories"), ]);
let mut metadata = String::new(); let mut current: Option<String> = None; let metadata_key_prefix = Regex::new(r"^(\w+):(.*)").unwrap(); let tags_line = Regex::new(r"^[]*-[ ]*(.*)").unwrap(); let mut published = false; let mut id: Option<String> = None; let mut last_key = ""; for line in self.metadata.lines() { if let Some(caps) = metadata_key_prefix.captures(line) { if let Some(current) = ¤t { metadata += current; }
let (key, value) = (caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()); last_key = key;
if key == "published" { if value.trim() == "true" { published = true } else { return Err(exit_codes::OK); } } else if key == "id" { id = Some(value.trim().to_string()); }
if let Some(key) = metadata_mappings.get(&key) { current = Some(format!("{key}:{value}\n")) } else { current = None; } } else if let Some(value) = current { current = Some(value + &format!("{line}\n")); } else if last_key == "tags" { if let Some(caps) = tags_line.captures(line) { let tag = caps.get(1).unwrap().as_str(); self.tags.push(tag.to_string()) } } } self.metadata = format!("title: \"{title}\"\n{metadata}");
if published && id != "" { match id { Some(id) => Ok(id), None => Err(ID_ABSENT_AND_SKIP), } } else { Err(NOT_PUBLISHED_AND_SKIP) }
|
该代码片段中,我们处理了三个特殊的元数据,一个是 id
,表示文章的 ID,如果它的值不存在,我们认为它不应该被发布;另外一个元数据是 tags
,由于 Obsidian 我使用了 Obsidian Prettifier 插件,它会直接把 tags
强制转换成数组类型,所以这里我直接认为 tags
一定是空定义一行,然后后面若干行的 -
打头的数组的形式;最后,需要单独找出来 published
的值,如果是 false
或者不存在,表示不发布,退出即可。
注意
这里的处理逻辑,不同的人习惯不一样,而且和 Obsidian 的文章的元数据关联性极大,需要按自己的习惯进行处理。比如说,如果你的 tags
可以是单个(一行)或者多个的情况,那得自己特殊处理。另外由于 rust 没有比较好的 Makrdown 文件的库,为了简化需求,这里我直接用正则强行搞了,没有考虑扩展性。
内容标签处理
由于个人的习惯,我会在文章的开关标记这个文章的标签,而不一定会放在元数据中,故我做了一下内容这边的处理,比如说我的文章长这个样子:
1 2 3 4 5 6 7 8
| --- metadata: key ---
#blog #obsidian
内容正文
|
这里,我直接把文章开头的标签抠出来,放到元数据的标签中进行合并(注意,老的文章有可能已经有一些 tags
了,所以这里是做合并),然后把它们从正文中去掉,当遇到不是空行也不是标签定义行的时候,就不做任何处理。
此外,我们需要把 Obsidian Callouts 转义成 Bootstrap Callouts,这里也只是简单做了处理。具体的对比如下:
1 2 3
| > [!tag-kind|indent] 提示 > > 内容
|
1 2 3 4 5
| {% note tag-kind %} <p style="text-decoration: underline;"> 提示 </p>
内容 {% endnote %}
|
大概的实现片断如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| let mut content = String::new(); let mut beginning = true; let mut in_note_block = false;
let tags_line = Regex::new("^[]*#([^ ]+)(.*)").unwrap(); let note = Regex::new( r"^>[]*\[(!(default|primary|success|info|warning|danger))(\|\w+)?][ ]*(\w+)", ) .unwrap(); let quote = Regex::new(r"^>[ ]*(.*)").unwrap(); for line in self.content.lines() { if beginning && line.trim().starts_with("#") { let mut line = line; let mut ok = true; while ok { ok = false; if let Some(caps) = tags_line.captures(line) { if let Some(tag) = caps.get(1) { self.tags.push(tag.as_str().to_string()); line = caps.get(2).unwrap().as_str(); ok = true } } }
continue; } else if line.trim() != "" { beginning = false; }
if !in_note_block { if let Some(caps) = note.captures(line) { let (tag, title) = (caps.get(2).unwrap().as_str(), caps.get(4).unwrap().as_str()); in_note_block = true; content += &format!("{{% note {tag} %}}\n<p style=\"text-decoration: underline;\">{title}</p>\n") } else { content += &format!("{line}\n"); } continue; }
if let Some(caps) = quote.captures(line) { let line = caps.get(1).unwrap().as_str(); content += &format!("{line}\n"); } else { in_note_block = false; content += &format!("{{% endnote %}}\n"); content += &format!("{line}\n"); } }
if in_note_block { content += &format!("{{% endnote %}}\n"); } self.content = content;
|
自动化
如果有必要,可以考虑用 CI/CD 来进行部署,不过由于我的博客是直接用 hexo-deployer-git
的,故我的部署和发布脚本是放在一个叫 deploy.zsh
的脚本中,这个脚本第一步做同步的工作,第二步进行 hexo deploy
。