M6: transliterate non-ASCII layer names into code-export class names (#1235)

class_name runs layer names through deunicode (new dependency: pure Rust
tables, zero deps, BSD-3-Clause, the slug crate's base) before its ASCII
filter, so Korean, Cyrillic, Latin-diacritic and CJK names become
readable ASCII classes in HTML/CSS/React/Leptos output, component roots
and data-* variant attributes: 로그인 화면 → rogeuin-hwamyeon, кнопка →
knopka, über → uber, 按钮 → an-niu (before, every one came out layer-,
layer--2, ...). Symbols-only names take the node's kind (.text, .frame)
instead of layer-, the 40-character cut lands on a word boundary, and a
leading digit keeps the layer- prefix. SwiftUI/Compose type names are
unchanged (their own sanitizer); ASCII-only names are byte-identical.
html_codegen_tests updated and extended.
This commit is contained in:
lileeei
2026-10-26 12:23:06 +08:00
parent 22c15e3206
commit aba296c6b8
8 changed files with 114 additions and 12 deletions
+48 -6
View File
@@ -594,18 +594,60 @@ fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
// ------------------------------------------------------------------ HTML and React markup
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`.
pub(crate) fn class_name(name: &str, used: &mut Vec<String>) -> String {
/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" →
/// `.text`, `.frame`), instead of the anonymous `layer-`.
fn kind_word(n: &Node) -> &str {
match &n.kind {
NodeKind::Page { .. } => "page",
NodeKind::Frame => "frame",
NodeKind::Group => "group",
NodeKind::Section { .. } => "section",
NodeKind::Rectangle => "rectangle",
NodeKind::Ellipse { .. } => "ellipse",
NodeKind::Polygon { .. } => "polygon",
NodeKind::Star { .. } => "star",
NodeKind::Line => "line",
NodeKind::Vector { .. } => "vector",
NodeKind::Text(_) => "text",
NodeKind::BooleanOp { .. } => "boolean",
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component",
NodeKind::Instance { .. } => "instance",
NodeKind::Slice => "slice",
}
}
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name
/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 →
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
let mut base = String::new();
for c in name.chars().flat_map(char::to_lowercase) {
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
base.push(c);
} else if !base.ends_with('-') && !base.is_empty() {
base.push('-');
}
}
let base = base.trim_end_matches('-').chars().take(40).collect::<String>();
let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base };
// The 40-character cut lands on a word boundary when there is one past the first word.
let base = if base.chars().count() > 40 {
let cut: String = base.chars().take(40).collect();
match cut.rfind('-') {
Some(i) if i > 0 => cut[..i].to_string(),
_ => cut,
}
} else {
base
};
let base = base.trim_end_matches('-');
let base = if base.is_empty() {
kind.to_string()
} else if base.starts_with(|c: char| c.is_ascii_digit()) {
format!("layer-{base}")
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
name
@@ -726,7 +768,7 @@ impl<'c, 'a> Markup<'c, 'a> {
self.used.push(c.clone());
c
}
None => class_name(&n.name, &mut self.used),
None => class_name(&n.name, kind_word(n), &mut self.used),
};
let pad = self.pad(depth);
let attr = if self.lang == CodeLang::React { "className" } else { "class" };