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:
parent
cc4c551aaa
commit
56f599574f
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user