feat(slug): 中文标题自动转拼音生成 URL slug

文章 URL slug 与 markdown 标题锚点此前会吃掉中文字符:纯中文标题
退化成时间戳,混入 ascii 时只剩 ascii 片段。

引入 pinyin crate(纯 Rust、零依赖,安全交叉编译到 musl/FreeBSD),
slugify 与 slugify_heading 在字符过滤前先尝试 to_pinyin():
- 汉字 → 无声调拼音,每字成词用 - 分隔(你好 → ni-hao)
- ASCII 字母数字与 -/_ 保留
- 多音字取默认读音,博客场景足够

示例:你好世界 → ni-hao-shi-jie;Rust 入门指南 → rust-ru-men-zhi-nan

is_valid_slug 不变(原本就允许 Unicode),手动输入中文 slug 仍可用。
pinyin 为 server-only optional 依赖,不进 WASM bundle。
This commit is contained in:
xfy 2026-07-13 18:04:35 +08:00
parent cc4c551aaa
commit 56f599574f
4 changed files with 67 additions and 19 deletions

7
Cargo.lock generated
View File

@ -3272,6 +3272,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pinyin"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@ -5491,6 +5497,7 @@ dependencies = [
"lol_html", "lol_html",
"md-5 0.10.6", "md-5 0.10.6",
"moka", "moka",
"pinyin",
"pulldown-cmark", "pulldown-cmark",
"rand 0.8.6", "rand 0.8.6",
"regex", "regex",

View File

@ -45,6 +45,8 @@ governor = { version = "0.10", optional = true }
sysinfo = { version = "0.39", optional = true } sysinfo = { version = "0.39", optional = true }
sqlparser = { version = "0.62", optional = true } sqlparser = { version = "0.62", optional = true }
dashmap = { version = "6.2", optional = true } dashmap = { version = "6.2", optional = true }
# 中文标题 → 拼音 slug纯 Rust、零依赖安全交叉编译到 musl/FreeBSD
pinyin = { version = "0.11", optional = true }
md-5 = { version = "0.10", optional = true } md-5 = { version = "0.10", optional = true }
futures = { version = "0.3", optional = true } futures = { version = "0.3", optional = true }
bytes = { version = "1", optional = true } bytes = { version = "1", optional = true }
@ -106,6 +108,7 @@ server = [
"dep:sysinfo", "dep:sysinfo",
"dep:sqlparser", "dep:sqlparser",
"dep:dashmap", "dep:dashmap",
"dep:pinyin",
"dep:md-5", "dep:md-5",
"dep:futures", "dep:futures",
"dep:bytes", "dep:bytes",

View File

@ -361,12 +361,23 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 将标题文本转换为可用于锚点的 slug。 /// 将标题文本转换为可用于锚点的 slug。
///
/// 汉字转无声调拼音(每字成词,用 `-` 分隔),与 [`crate::api::slug::slugify`]
/// 保持一致ASCII 字母数字保留,其余字符作为词分隔。结果为空时回退 `heading`。
fn slugify_heading(text: &str) -> String { fn slugify_heading(text: &str) -> String {
use pinyin::ToPinyin;
let mut slug = String::new(); let mut slug = String::new();
let mut prev_dash = true; let mut prev_dash = true;
for c in text.to_lowercase().chars() { for c in text.to_lowercase().chars() {
if c.is_alphanumeric() { // 汉字优先转拼音;非汉字(含 ascii返回 None。
// 每个汉字成词,拼音后补 `-` 与后续内容分隔(连续汉字也会被分开)。
if let Some(py) = c.to_pinyin() {
slug.push_str(py.plain());
slug.push('-');
prev_dash = true;
} else if c.is_alphanumeric() {
slug.push(c); slug.push(c);
prev_dash = false; prev_dash = false;
} else if !prev_dash { } else if !prev_dash {
@ -503,8 +514,14 @@ mod tests {
#[test] #[test]
fn slugify_heading_chinese() { fn slugify_heading_chinese() {
let slug = slugify_heading("你好世界"); // 汉字逐字转拼音,用 `-` 分隔。
assert!(!slug.is_empty()); assert_eq!(slugify_heading("你好世界"), "ni-hao-shi-jie");
}
#[test]
fn slugify_heading_mixed_chinese_ascii() {
// Rust 入门指南 → rust + ru-men-zhi-nan词之间用 `-` 分隔。
assert_eq!(slugify_heading("Rust 入门指南"), "rust-ru-men-zhi-nan");
} }
#[test] #[test]

View File

@ -9,26 +9,34 @@
#[cfg(feature = "server")] #[cfg(feature = "server")]
use dioxus::prelude::*; use dioxus::prelude::*;
#[cfg(feature = "server")]
use pinyin::ToPinyin;
#[cfg(feature = "server")] #[cfg(feature = "server")]
/// 将标题转换为 URL 友好的 slug。 /// 将标题转换为 URL 友好的 slug。
/// ///
/// 非字母数字字符替换为 `-` 并合并连续 `-`,结果截断至 100 字符; /// 汉字转为无声调拼音(每字独立成词,用 `-` 分隔,如 `你好` → `ni-hao`
/// 若全部字符被过滤,则返回当前时间戳作为 slug。 /// ASCII 字母数字与 `-`/`_` 保留;其余字符替换为 `-`,连续 `-` 合并。
/// 结果截断至 100 字符;若全部被过滤则回退为当前时间戳。
///
/// 多音字取默认读音(不处理歧义),博客 slug 场景足够。
pub fn slugify(title: &str) -> String { pub fn slugify(title: &str) -> String {
let slug: String = title let mut out = String::with_capacity(title.len());
.to_lowercase() for c in title.to_lowercase().chars() {
.chars() // 汉字优先转拼音to_pinyin() 对非汉字(含 ascii返回 None。
.map(|c| { if let Some(py) = c.to_pinyin() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' { out.push_str(py.plain());
c // 每个汉字成词,用 `-` 与后续内容分隔。
out.push('-');
} else if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else { } else {
'-' out.push('-');
}
} }
})
.collect();
// 合并连续的连字符,并去除首尾空段。 // 合并连续的连字符,并去除首尾空段。
let parts: Vec<&str> = slug.split('-').filter(|s| !s.is_empty()).collect(); let parts: Vec<&str> = out.split('-').filter(|s| !s.is_empty()).collect();
let slug = parts.join("-"); let slug = parts.join("-");
if slug.is_empty() { if slug.is_empty() {
@ -116,8 +124,21 @@ mod tests {
#[test] #[test]
fn slugify_chinese_characters() { fn slugify_chinese_characters() {
let slug = slugify("你好世界 hello"); // 汉字逐字转拼音,用 `-` 分隔;混入的 ascii 自然衔接到末尾。
assert!(slug.contains("hello")); assert_eq!(slugify("你好世界"), "ni-hao-shi-jie");
assert_eq!(slugify("你好世界 hello"), "ni-hao-shi-jie-hello");
}
#[test]
fn slugify_mixed_chinese_ascii() {
// Rust 入门指南 → rust + ru-men-zhi-nan
assert_eq!(slugify("Rust 入门指南"), "rust-ru-men-zhi-nan");
}
#[test]
fn slugify_chinese_with_punctuation() {
// 标点替换为 `-`,随后与拼音分隔符合并。
assert_eq!(slugify("你好,世界!"), "ni-hao-shi-jie");
} }
#[test] #[test]