mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 10:36:27 +03:00
"Do's, and Don'ts" and "Do and/or Don't" put a comma and a conjunction, or a conjunction and a slash, between the two halves, and a joiner that accepted exactly one token read those headings as negative and dropped the classes their "Do" sections sanction. One joiner or several, up to four. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2532 lines
88 KiB
Rust
2532 lines
88 KiB
Rust
//! Port of `cli/engine/design-system.mjs`: DESIGN.md discovery (walk-up with
|
|
//! project boundaries), the frontmatter YAML subset, sidecar parsing, the
|
|
//! normalized allowlists, the source-scan design-system rules, and the
|
|
//! finding merge/dedupe helpers.
|
|
//!
|
|
//! The DOM-backed `collectStaticDesignSystemFindings` lives with the static
|
|
//! HTML engine (crates/html); it builds on the `pub` helpers here
|
|
//! (`primary_font`, `is_allowed_*`, `css_color_label`, `extract_radius_tokens`,
|
|
//! `make_design_finding`, `is_transparent_css`, `STATIC_DESIGN_SKIP_TAGS`), so
|
|
//! the html crate depends on this crate, never the reverse.
|
|
|
|
use std::collections::HashMap;
|
|
use std::rc::Rc;
|
|
|
|
use impeccable_core::checks::measures::resolve_length_px;
|
|
use impeccable_core::color::{parse_any_color, Rgba};
|
|
use impeccable_core::constants::GENERIC_FONTS;
|
|
use impeccable_core::findings::{finding, Finding};
|
|
use impeccable_core::js::{self, ci, math_round, number_to_string, parse_float, parse_int};
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
use serde_json::{Map, Value};
|
|
|
|
use crate::jsp;
|
|
use crate::util::{exists, js_string, re, read_json, read_text, ANY, WS};
|
|
|
|
const DESIGN_NAMES: &[&str] = &["DESIGN.md", "Design.md", "design.md"];
|
|
|
|
const FALLBACK_DIRS: &[&str] = &[".agents/context", "docs"];
|
|
const PROJECT_ROOT_MARKERS: &[&str] = &[".git", "package.json", ".impeccable"];
|
|
const COLOR_CHANNEL_TOLERANCE: f64 = 6.0;
|
|
const SHADOW_ALPHA_TOLERANCE: f64 = 0.02;
|
|
const RADIUS_TOLERANCE_PX: f64 = 0.5;
|
|
const FONT_SIZE_TOLERANCE_PX: f64 = 0.5;
|
|
|
|
/// JS `STATIC_DESIGN_SKIP_TAGS`.
|
|
pub const STATIC_DESIGN_SKIP_TAGS: &[&str] = &[
|
|
"head", "title", "meta", "link", "style", "script", "noscript", "template", "source",
|
|
];
|
|
|
|
re!(DESIGN_BACKTICKED, "`([^`\n]{1,80})`".to_string());
|
|
re!(
|
|
DESIGN_CLASS_SELECTOR,
|
|
"^\\.[A-Za-z_][A-Za-z0-9_-]*$".to_string()
|
|
);
|
|
// A design document's prose, cut where one statement stops and the next
|
|
// starts: punctuation, a line break, and the phrases that turn a sentence
|
|
// around. `instead of` / `rather than` open a
|
|
// clause about what the document is steering *away* from; the reversals
|
|
// (`outside`, `except`, ...) open one about what it is steering *toward*,
|
|
// which is what lets "no ALL CAPS outside the `.eyebrow` class" declare
|
|
// `.eyebrow`.
|
|
re!(
|
|
DESIGN_CLAUSE_SPLIT,
|
|
r"(?i)[.!?;:,()\[\]\n]|\u{2014}|\u{2013}|\binstead of\b|\brather than\b|\bas opposed to\b|\boutside\b|\bexcept\b|\bother than\b|\bunless\b|\bbesides\b|\bapart from\b|\bbeyond\b".to_string()
|
|
);
|
|
re!(
|
|
DESIGN_NEGATING_BOUNDARY,
|
|
r"(?i)^(?:instead of|rather than|as opposed to)$".to_string()
|
|
);
|
|
// A directive: it condemns what comes after it, and nothing before it.
|
|
// "Use `.kicker` and never `.tagline`" sanctions the first and forbids the
|
|
// second, and a rule that read the whole clause would lose both.
|
|
re!(
|
|
DESIGN_DIRECTIVE_NEGATIVE,
|
|
r"(?i)\b(?:no|not|never|nor|none|avoid\w*|don'?t|do not|doesn'?t|does not|drop|remove\w*|stop|skip)\b".to_string()
|
|
);
|
|
// A state: it describes whatever its clause is about, wherever in the clause
|
|
// the name sits. "`.card-old` is deprecated" names the class first.
|
|
re!(
|
|
DESIGN_STATE_NEGATIVE,
|
|
r"(?i)\b(?:deprecat\w*|obsolete|legacy|forbidden|banned|disallow\w*|discourag\w*|retired|unsupported|wrong|bad|anti-?pattern\w*|no longer|not allowed|not permitted|not supported|not used)\b".to_string()
|
|
);
|
|
// Headings that introduce a section of counter-examples.
|
|
re!(
|
|
DESIGN_NEGATIVE_HEADING,
|
|
r"(?i)\b(?:don'?ts?|do not|avoid|never|not to|anti-?patterns?|deprecat\w*|forbidden|banned|legacy|obsolete|retired|unsupported|removed|discourag\w*|disallow\w*|mistakes?|wrong|bad)\b".to_string()
|
|
);
|
|
// A heading that names both sides — "Do and Don't", "Dos and Don'ts",
|
|
// "Do / Do not" — introduces a section of both, so the subsections under it
|
|
// say which is which and the heading itself condemns nothing. The two sides
|
|
// have to be *joined* by something that pairs them: "Don't do this" and "What
|
|
// we don't do" also put a `do` beside a `don't`, and they mean only the one
|
|
// thing.
|
|
re!(DESIGN_BOTH_SIDES_HEADING, {
|
|
// One joiner or several: "Do's, and Don'ts" and "Do and/or Don't" pair
|
|
// the two sides with a comma plus a conjunction and with a conjunction
|
|
// plus a slash.
|
|
let joiner = format!(r"(?:{WS}*(?:and|or|&|/|\||\+|,|vs\.?|versus)){{1,4}}{WS}*", WS = WS);
|
|
let affirmative = r"\bdo'?s?\b";
|
|
let negative = r"\b(?:do ?n[o']?ts?|do not)\b";
|
|
format!("(?i)(?:{affirmative}{joiner}{negative}|{negative}{joiner}{affirmative})")
|
|
});
|
|
re!(
|
|
FONT_SIZE_LITERAL_RE,
|
|
format!("^-?[{D}.]+(?:px|rem)$", D = "0-9")
|
|
);
|
|
re!(
|
|
CSS_COLOR_RE,
|
|
format!(
|
|
"#[0-9a-fA-F]{{3,8}}(?-u:\\b)|{rgb}[aA]?\\([^)]+\\)|{oklch}\\([^)]+\\)|{hsl}[aA]?\\([^)]+\\)",
|
|
rgb = ci("rgb"),
|
|
oklch = ci("oklch"),
|
|
hsl = ci("hsl")
|
|
)
|
|
);
|
|
re!(
|
|
FONT_DECL_RE,
|
|
format!("{}{WS}*:{WS}*([^;}}\n]+)", ci("font-family"))
|
|
);
|
|
re!(
|
|
FONT_JS_RE,
|
|
format!("fontFamily{WS}*[:=]{WS}*[\"'`]([^\"'`]+)[\"'`]")
|
|
);
|
|
re!(
|
|
GOOGLE_FONT_RE,
|
|
format!(
|
|
"{}\\.{}\\.{}/{}2?\\?[^\"'{}){}<>]*",
|
|
ci("fonts"),
|
|
ci("googleapis"),
|
|
ci("com"),
|
|
ci("css"),
|
|
impeccable_core::js::WS_CHARS,
|
|
""
|
|
)
|
|
);
|
|
re!(
|
|
BORDER_RADIUS_RE,
|
|
format!("{}{WS}*:{WS}*([^;}}\n]+)", ci("border-radius"))
|
|
);
|
|
re!(
|
|
BORDER_RADIUS_JS_RE,
|
|
format!("borderRadius{WS}*[:=]{WS}*[\"'`]([^\"'`]+)[\"'`]")
|
|
);
|
|
re!(
|
|
FONT_SIZE_DECL_RE,
|
|
format!("{}{WS}*:{WS}*([^;}}\n]+)", ci("font-size"))
|
|
);
|
|
re!(
|
|
FONT_SIZE_JS_RE,
|
|
format!("fontSize{WS}*[:=]{WS}*[\"'`]([^\"'`]+)[\"'`]")
|
|
);
|
|
re!(
|
|
TAILWIND_FONT_SIZE_RE,
|
|
format!("(?-u:\\b)text-\\[(-?[0-9.]+(?:px|rem))\\]")
|
|
);
|
|
re!(GOOGLE_FAMILY_PARAM_RE, "[?&]family=([^&]+)");
|
|
re!(
|
|
IMPORTANT_TAIL_RE,
|
|
format!("{WS}*{}{WS}*$", ci("!important"))
|
|
);
|
|
re!(IMPORTANT_TAIL_CS_RE, format!("{WS}*!important{WS}*$"));
|
|
re!(EDGE_QUOTE_RE, r#"^["']|["']$"#);
|
|
re!(WS_RUN_RE, format!("{WS}+"));
|
|
re!(VAR_RE, format!("{}\\(", ci("var")));
|
|
|
|
fn first_existing(dir: &str, names: &[&str]) -> Option<String> {
|
|
for name in names {
|
|
let abs = jsp::join(&[dir, name]);
|
|
if exists(&abs) {
|
|
return Some(abs);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// `{ path, contextDir }` of the DESIGN.md that governs `cwd`.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct DesignMdPath {
|
|
pub path: String,
|
|
pub context_dir: String,
|
|
}
|
|
|
|
/// JS: design-system.mjs#resolveDesignMdPath
|
|
pub fn resolve_design_md_path(cwd: &str) -> Option<DesignMdPath> {
|
|
if let Some(root) = first_existing(cwd, DESIGN_NAMES) {
|
|
return Some(DesignMdPath {
|
|
path: root,
|
|
context_dir: cwd.to_string(),
|
|
});
|
|
}
|
|
for rel in FALLBACK_DIRS {
|
|
let dir = jsp::resolve(cwd, &[rel]);
|
|
if let Some(found) = first_existing(&dir, DESIGN_NAMES) {
|
|
return Some(DesignMdPath {
|
|
path: found,
|
|
context_dir: dir,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// JS: design-system.mjs#resolveDesignSidecarPath
|
|
pub fn resolve_design_sidecar_path(cwd: &str, context_dir: &str) -> Option<String> {
|
|
let candidates = [
|
|
jsp::join(&[cwd, ".impeccable", "design.json"]),
|
|
jsp::join(&[cwd, "DESIGN.json"]),
|
|
jsp::join(&[context_dir, "DESIGN.json"]),
|
|
];
|
|
for (index, candidate) in candidates.iter().enumerate() {
|
|
let first = candidates
|
|
.iter()
|
|
.position(|c| c == candidate)
|
|
.unwrap_or(index);
|
|
if first == index && exists(candidate) {
|
|
return Some(candidate.clone());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// ─── Frontmatter YAML subset ─────────────────────────────────────────────────
|
|
|
|
re!(CRLF_RE, "\r?\n");
|
|
re!(COMMENT_LINE_RE, format!("^{WS}*#"));
|
|
re!(LEADING_WS_RE, format!("^{WS}*"));
|
|
|
|
/// JS: design-system.mjs#parseFrontmatter. `None` when there is no
|
|
/// `---` block; otherwise the parsed object (possibly empty).
|
|
/// Class selectors the design document names as its own, in the order it
|
|
/// names them.
|
|
///
|
|
/// A design document writes a component in backticks — "No ALL CAPS outside
|
|
/// the `.eyebrow` class" — and that is the repository declaring a pattern by
|
|
/// name. A rule that fires on one of those is reviewing the design system
|
|
/// rather than the change, so the browser rules read this list and stand
|
|
/// down (REN-406). Only a plain class selector counts: a backticked file
|
|
/// name, property or hex is not a component.
|
|
///
|
|
/// Not every class a document names is a class it sanctions. A document also
|
|
/// writes down what it does *not* want — "Avoid `.eyebrow`", "`.card-old` is
|
|
/// deprecated", a "Don't" section of counter-examples — and exempting those
|
|
/// would silence exactly the misuse the document was written to forbid
|
|
/// (REN-406 follow-up). So each occurrence is read in the document's own
|
|
/// structure: the heading chain above it, and the clause it sits in. A class
|
|
/// condemned anywhere in the document is declared nowhere — a document that
|
|
/// says "deprecated" about a class has said enough.
|
|
pub fn declared_component_selectors(design_md: &str) -> Vec<String> {
|
|
// The prose is cut into clauses on punctuation, and a code span is full
|
|
// of punctuation that is not prose: `.btn-primary` carries a full stop,
|
|
// `rgb(0, 0, 0)` a pair of brackets and a comma. Masking every span to a
|
|
// run of letters of the same byte length keeps every offset where it was.
|
|
let masked = mask_code_spans(design_md);
|
|
let mut declared: Vec<String> = Vec::new();
|
|
let mut condemned: Vec<String> = Vec::new();
|
|
for cap in DESIGN_BACKTICKED.captures_iter(design_md) {
|
|
let span = cap.get(0).expect("whole match");
|
|
let token = js::trim(cap.get(1).map(|m| m.as_str()).unwrap_or(""));
|
|
if !DESIGN_CLASS_SELECTOR.is_match(token) {
|
|
continue;
|
|
}
|
|
let token = token.to_string();
|
|
let list = if design_condemns(&masked, span.start(), span.end()) {
|
|
&mut condemned
|
|
} else {
|
|
&mut declared
|
|
};
|
|
if !list.contains(&token) {
|
|
list.push(token);
|
|
}
|
|
if declared.len() + condemned.len() >= 128 {
|
|
break;
|
|
}
|
|
}
|
|
declared.retain(|t| !condemned.contains(t));
|
|
declared.truncate(64);
|
|
declared
|
|
}
|
|
|
|
/// Every code span replaced by a run of `a` of the same byte length, so the
|
|
/// prose around it can be scanned for sentence structure without a class
|
|
/// selector's own dot ending a sentence. ASCII in, same length out, so byte
|
|
/// offsets into the original still address the same characters.
|
|
fn mask_code_spans(design_md: &str) -> String {
|
|
let mut masked = design_md.as_bytes().to_vec();
|
|
for span in DESIGN_BACKTICKED.find_iter(design_md) {
|
|
for b in &mut masked[span.start()..span.end()] {
|
|
*b = b'a';
|
|
}
|
|
}
|
|
String::from_utf8(masked).unwrap_or_else(|_| design_md.to_string())
|
|
}
|
|
|
|
/// Whether the document speaks against the code span at `[start, end)`:
|
|
/// either it sits under a heading that introduces counter-examples, or its
|
|
/// own clause carries a word that condemns what the clause names.
|
|
fn design_condemns(masked: &str, start: usize, end: usize) -> bool {
|
|
under_negative_heading(masked, start) || clause_condemns(masked, start, end)
|
|
}
|
|
|
|
/// The heading chain above `start`, by level: a subsection of a "Don't"
|
|
/// section is still inside it.
|
|
fn under_negative_heading(masked: &str, start: usize) -> bool {
|
|
let mut chain: Vec<(usize, &str)> = Vec::new();
|
|
for line in masked[..start].split('\n') {
|
|
let line = line.trim_start();
|
|
let level = line.bytes().take_while(|b| *b == b'#').count();
|
|
if level == 0 || level > 6 {
|
|
continue;
|
|
}
|
|
let text = &line[level..];
|
|
if !text.is_empty() && !text.starts_with(' ') {
|
|
continue;
|
|
}
|
|
chain.retain(|(l, _)| *l < level);
|
|
chain.push((level, text));
|
|
}
|
|
chain.iter().any(|(_, text)| {
|
|
// "Dos and Don'ts" heads a section of both, and the subsections
|
|
// under it are what say which is which.
|
|
DESIGN_NEGATIVE_HEADING.is_match(text) && !DESIGN_BOTH_SIDES_HEADING.is_match(text)
|
|
})
|
|
}
|
|
|
|
/// The clause the span sits in — from the boundary before it to the boundary
|
|
/// after it — and whether that clause condemns what it names.
|
|
///
|
|
/// Where the negative word sits decides what it governs. A *state* ("`.x` is
|
|
/// deprecated") describes whatever the clause is about, so it condemns the
|
|
/// class wherever in the clause the name appears. A *directive* ("never use
|
|
/// `.x`") condemns what follows it and nothing before it, which is what keeps
|
|
/// "Use `.kicker` and never `.tagline`" from losing `.kicker`. A clause
|
|
/// opened by "instead of" or "rather than" is condemned by the boundary
|
|
/// itself, whatever words follow.
|
|
fn clause_condemns(masked: &str, start: usize, end: usize) -> bool {
|
|
let mut clause_start = 0usize;
|
|
let mut opened_by_negation = false;
|
|
for boundary in DESIGN_CLAUSE_SPLIT.find_iter(&masked[..start]) {
|
|
clause_start = boundary.end();
|
|
opened_by_negation = DESIGN_NEGATING_BOUNDARY.is_match(js::trim(boundary.as_str()));
|
|
}
|
|
if opened_by_negation {
|
|
return true;
|
|
}
|
|
let clause_end = DESIGN_CLAUSE_SPLIT
|
|
.find(&masked[end..])
|
|
.map(|m| end + m.start())
|
|
.unwrap_or(masked.len());
|
|
DESIGN_STATE_NEGATIVE.is_match(&masked[clause_start..clause_end])
|
|
|| DESIGN_DIRECTIVE_NEGATIVE.is_match(&masked[clause_start..start])
|
|
}
|
|
|
|
pub fn parse_frontmatter(md: &str) -> Option<Map<String, Value>> {
|
|
let lines: Vec<&str> = CRLF_RE.split(md).collect();
|
|
if js::trim(lines.first().copied().unwrap_or("")) != "---" {
|
|
return None;
|
|
}
|
|
let mut end = None;
|
|
for (i, line) in lines.iter().enumerate().skip(1) {
|
|
if js::trim(line) == "---" {
|
|
end = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
let end = end?;
|
|
Some(parse_yaml_subset(&lines[1..end].join("\n")))
|
|
}
|
|
|
|
/// JS: design-system.mjs#parseYamlSubset (nested maps and scalars only).
|
|
pub fn parse_yaml_subset(yaml: &str) -> Map<String, Value> {
|
|
// Build a tree of paths, then materialize: JS mutates nested objects by
|
|
// reference; here we track the key path of each open mapping.
|
|
let mut root = Map::new();
|
|
let mut stack: Vec<(i64, Vec<String>)> = vec![(-1, vec![])];
|
|
for raw in CRLF_RE.split(yaml) {
|
|
if js::trim(raw).is_empty() || COMMENT_LINE_RE.is_match(raw) {
|
|
continue;
|
|
}
|
|
let indent_bytes = LEADING_WS_RE.find(raw).map(|m| m.end()).unwrap_or(0);
|
|
let indent = raw[..indent_bytes]
|
|
.chars()
|
|
.map(|c| c.len_utf16())
|
|
.sum::<usize>() as i64;
|
|
let content = &raw[indent_bytes..];
|
|
let Some(colon_idx) = find_top_level_colon(content) else {
|
|
continue;
|
|
};
|
|
while stack.len() > 1 && stack.last().map(|s| s.0 >= indent).unwrap_or(false) {
|
|
stack.pop();
|
|
}
|
|
let key = unquote_yaml_key(js::trim(&content[..colon_idx])).to_string();
|
|
let rest = strip_inline_yaml_comment(js::trim(&content[colon_idx + 1..]));
|
|
let parent_path = stack.last().map(|s| s.1.clone()).unwrap_or_default();
|
|
let parent = get_path_mut(&mut root, &parent_path);
|
|
if rest.is_empty() {
|
|
parent.insert(key.clone(), Value::Object(Map::new()));
|
|
let mut path = parent_path;
|
|
path.push(key);
|
|
stack.push((indent, path));
|
|
} else {
|
|
parent.insert(key, parse_scalar(&rest));
|
|
}
|
|
}
|
|
root
|
|
}
|
|
|
|
fn get_path_mut<'a>(
|
|
root: &'a mut Map<String, Value>,
|
|
path: &[String],
|
|
) -> &'a mut Map<String, Value> {
|
|
let mut cur = root;
|
|
for key in path {
|
|
// A key on the stack was inserted as an object; a later scalar write to
|
|
// the same key at the parent level would already have popped the stack.
|
|
let entry = cur
|
|
.entry(key.clone())
|
|
.or_insert_with(|| Value::Object(Map::new()));
|
|
if !entry.is_object() {
|
|
*entry = Value::Object(Map::new());
|
|
}
|
|
cur = entry.as_object_mut().unwrap();
|
|
}
|
|
cur
|
|
}
|
|
|
|
fn find_top_level_colon(s: &str) -> Option<usize> {
|
|
let mut in_quote: Option<char> = None;
|
|
let mut prev: Option<char> = None;
|
|
for (i, ch) in s.char_indices() {
|
|
if let Some(q) = in_quote {
|
|
if ch == q && prev != Some('\\') {
|
|
in_quote = None;
|
|
}
|
|
} else if ch == '"' || ch == '\'' {
|
|
in_quote = Some(ch);
|
|
} else if ch == ':' {
|
|
return Some(i);
|
|
}
|
|
prev = Some(ch);
|
|
}
|
|
None
|
|
}
|
|
|
|
/// JS: design-system.mjs#unquoteYamlKey
|
|
pub fn unquote_yaml_key(key: &str) -> &str {
|
|
if (key.starts_with('"') && key.ends_with('"'))
|
|
|| (key.starts_with('\'') && key.ends_with('\''))
|
|
{
|
|
if key.chars().count() >= 2 {
|
|
return &key[1..key.len() - 1];
|
|
}
|
|
// JS slice(1, -1) on a one-char string yields ''.
|
|
return "";
|
|
}
|
|
key
|
|
}
|
|
|
|
fn strip_inline_yaml_comment(s: &str) -> String {
|
|
let mut in_quote: Option<char> = None;
|
|
let mut prev: Option<char> = None;
|
|
for (i, ch) in s.char_indices() {
|
|
if let Some(q) = in_quote {
|
|
if ch == q && prev != Some('\\') {
|
|
in_quote = None;
|
|
}
|
|
} else if ch == '"' || ch == '\'' {
|
|
in_quote = Some(ch);
|
|
} else if ch == '#' && i > 0 && prev.map(js::is_js_whitespace).unwrap_or(false) {
|
|
return s[..i].trim_end_matches(js::is_js_whitespace).to_string();
|
|
}
|
|
prev = Some(ch);
|
|
}
|
|
s.to_string()
|
|
}
|
|
|
|
fn yaml_simple_escape(c: char) -> Option<char> {
|
|
Some(match c {
|
|
'0' => '\0',
|
|
'a' => '\x07',
|
|
'b' => '\x08',
|
|
't' => '\t',
|
|
'n' => '\n',
|
|
'v' => '\x0B',
|
|
'f' => '\x0C',
|
|
'r' => '\r',
|
|
'e' => '\x1b',
|
|
' ' => ' ',
|
|
'"' => '"',
|
|
'/' => '/',
|
|
'\\' => '\\',
|
|
'N' => '\u{85}',
|
|
'_' => '\u{a0}',
|
|
'L' => '\u{2028}',
|
|
'P' => '\u{2029}',
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn unescape_yaml_double_quoted(body: &str) -> String {
|
|
let chars: Vec<char> = body.chars().collect();
|
|
let mut out = String::new();
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
let ch = chars[i];
|
|
if ch != '\\' || i == chars.len() - 1 {
|
|
out.push(ch);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let next = chars[i + 1];
|
|
if let Some(mapped) = yaml_simple_escape(next) {
|
|
out.push(mapped);
|
|
i += 2;
|
|
continue;
|
|
}
|
|
let hex_len = match next {
|
|
'x' => Some(2),
|
|
'u' => Some(4),
|
|
'U' => Some(8),
|
|
_ => None,
|
|
};
|
|
if let Some(hex_len) = hex_len {
|
|
let hex: String = chars[(i + 2).min(chars.len())..(i + 2 + hex_len).min(chars.len())]
|
|
.iter()
|
|
.collect();
|
|
let code_point: i64 =
|
|
if hex.chars().count() == hex_len && hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
parse_int(&hex, 16) as i64
|
|
} else {
|
|
-1
|
|
};
|
|
if (0..=0x10ffff).contains(&code_point) {
|
|
// String.fromCodePoint: a surrogate code point has no char
|
|
// representation; substitute U+FFFD as the closest thing.
|
|
out.push(char::from_u32(code_point as u32).unwrap_or('\u{fffd}'));
|
|
i += 2 + hex_len;
|
|
continue;
|
|
}
|
|
}
|
|
out.push(ch);
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
re!(INT_RE, "^-?[0-9]+$");
|
|
re!(FLOAT_RE, "^-?[0-9]*\\.[0-9]+$");
|
|
|
|
fn parse_scalar(raw: &str) -> Value {
|
|
let s = js::trim(raw);
|
|
let n = s.chars().count();
|
|
if n >= 2 && s.starts_with('"') && s.ends_with('"') {
|
|
return Value::String(unescape_yaml_double_quoted(&s[1..s.len() - 1]));
|
|
}
|
|
if n >= 2 && s.starts_with('\'') && s.ends_with('\'') {
|
|
return Value::String(s[1..s.len() - 1].replace("''", "'"));
|
|
}
|
|
if s == "true" {
|
|
return Value::Bool(true);
|
|
}
|
|
if s == "false" {
|
|
return Value::Bool(false);
|
|
}
|
|
if s == "null" || s == "~" {
|
|
return Value::Null;
|
|
}
|
|
if INT_RE.is_match(s) || FLOAT_RE.is_match(s) {
|
|
let v = js::string_to_number(s);
|
|
return serde_json::Number::from_f64(v)
|
|
.map(Value::Number)
|
|
.unwrap_or(Value::String(s.to_string()));
|
|
}
|
|
Value::String(s.to_string())
|
|
}
|
|
|
|
// ─── Normalized design system ────────────────────────────────────────────────
|
|
|
|
/// JS `normalizeFontName`.
|
|
pub fn normalize_font_name(value: &str) -> String {
|
|
let t = js::trim(value);
|
|
let t = IMPORTANT_TAIL_RE.replace(t, "");
|
|
let t = js::trim(&t);
|
|
let t = EDGE_QUOTE_RE.replace_all(t, "");
|
|
let t = t.replace('+', " ");
|
|
let t = WS_RUN_RE.replace_all(&t, " ");
|
|
js::to_lower_case(&t)
|
|
}
|
|
|
|
/// JS `splitFontStack`.
|
|
pub fn split_font_stack(stack: &str) -> Vec<String> {
|
|
let t = IMPORTANT_TAIL_RE.replace(stack, "");
|
|
t.split(',')
|
|
.map(normalize_font_name)
|
|
.filter(|f| !f.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn is_generic_font(font: &str) -> bool {
|
|
GENERIC_FONTS.contains(&font)
|
|
}
|
|
|
|
re!(NON_LITERAL_STACK_RE, format!("[$`{{}}]|{WS}\\+{WS}|\\|\\|"));
|
|
|
|
fn is_literal_font_stack(stack: &str) -> bool {
|
|
!NON_LITERAL_STACK_RE.is_match(stack)
|
|
}
|
|
|
|
/// JS: design-system.mjs#primaryFont
|
|
pub fn primary_font(stack: &str) -> String {
|
|
if stack.is_empty() || VAR_RE.is_match(stack) || !is_literal_font_stack(stack) {
|
|
return String::new();
|
|
}
|
|
split_font_stack(stack)
|
|
.into_iter()
|
|
.find(|f| !is_generic_font(f))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// JS: design-system.mjs#cssColorLabel
|
|
pub fn css_color_label(raw: &str) -> String {
|
|
WS_RUN_RE.replace_all(js::trim(raw), " ").into_owned()
|
|
}
|
|
|
|
/// JS `colorKey`.
|
|
pub fn color_key(color: &Rgba) -> String {
|
|
format!(
|
|
"{},{},{}",
|
|
number_to_string(color.r),
|
|
number_to_string(color.g),
|
|
number_to_string(color.b)
|
|
)
|
|
}
|
|
|
|
fn colors_close(a: &Rgba, b: &Rgba) -> bool {
|
|
js::math_max3((a.r - b.r).abs(), (a.g - b.g).abs(), (a.b - b.b).abs())
|
|
<= COLOR_CHANNEL_TOLERANCE
|
|
}
|
|
|
|
fn hsl_to_rgb(hh: f64, ss: f64, ll: f64, alpha: f64) -> Rgba {
|
|
let h = (((hh % 360.0) + 360.0) % 360.0) / 360.0;
|
|
let s = js::math_max(0.0, js::math_min(1.0, ss));
|
|
let l = js::math_max(0.0, js::math_min(1.0, ll));
|
|
let hue2rgb = |p: f64, q: f64, mut t: f64| {
|
|
if t < 0.0 {
|
|
t += 1.0;
|
|
}
|
|
if t > 1.0 {
|
|
t -= 1.0;
|
|
}
|
|
if t < 1.0 / 6.0 {
|
|
return p + (q - p) * 6.0 * t;
|
|
}
|
|
if t < 1.0 / 2.0 {
|
|
return q;
|
|
}
|
|
if t < 2.0 / 3.0 {
|
|
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
|
}
|
|
p
|
|
};
|
|
let q = if l < 0.5 {
|
|
l * (1.0 + s)
|
|
} else {
|
|
l + s - l * s
|
|
};
|
|
let p = 2.0 * l - q;
|
|
Rgba::new(
|
|
math_round(hue2rgb(p, q, h + 1.0 / 3.0) * 255.0),
|
|
math_round(hue2rgb(p, q, h) * 255.0),
|
|
math_round(hue2rgb(p, q, h - 1.0 / 3.0) * 255.0),
|
|
alpha,
|
|
)
|
|
}
|
|
|
|
re!(
|
|
HSL_FALLBACK_RE,
|
|
format!(
|
|
"{hsl}[aA]?\\({WS}*([-0-9.]+)(?:{deg})?{WS}*,?{WS}*([0-9.]+)%{WS}*,?{WS}*([0-9.]+)%(?:{WS}*[,/]{WS}*([0-9.]+))?{WS}*\\)",
|
|
hsl = ci("hsl"),
|
|
deg = ci("deg")
|
|
)
|
|
);
|
|
|
|
/// JS: design-system.mjs#parseDesignColor
|
|
pub fn parse_design_color(value: &str) -> Option<Rgba> {
|
|
let text = js::trim(value);
|
|
if let Some(parsed) = parse_any_color(Some(text)) {
|
|
return Some(parsed);
|
|
}
|
|
let m = HSL_FALLBACK_RE.captures(text)?;
|
|
Some(hsl_to_rgb(
|
|
parse_float(&m[1]),
|
|
parse_float(&m[2]) / 100.0,
|
|
parse_float(&m[3]) / 100.0,
|
|
m.get(4).map(|a| parse_float(a.as_str())).unwrap_or(1.0),
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct AllowedColor {
|
|
pub color: Rgba,
|
|
pub labels: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct AllowedRadius {
|
|
pub name: String,
|
|
pub value: String,
|
|
pub px: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct AllowedFontSize {
|
|
pub value: String,
|
|
pub px: f64,
|
|
pub fluid: bool,
|
|
}
|
|
|
|
/// JS `normalizeDesignSystem` result.
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct DesignSystem {
|
|
pub present: bool,
|
|
/// Class selectors the document names as its own (REN-406). Filled where
|
|
/// the markdown itself is at hand; the allowlists come from frontmatter
|
|
/// and sidecar, this comes from the prose.
|
|
pub declared_selectors: Vec<String>,
|
|
pub source_path: Option<String>,
|
|
pub sidecar_path: Option<String>,
|
|
pub md_newer_than_json: bool,
|
|
/// JS `Set` (insertion order).
|
|
pub allowed_fonts: Vec<String>,
|
|
/// JS `Map<colorKey, { color, labels }>` (insertion order).
|
|
pub allowed_color_keys: Vec<(String, AllowedColor)>,
|
|
pub allowed_radii: Vec<AllowedRadius>,
|
|
pub allowed_font_sizes: Vec<AllowedFontSize>,
|
|
pub allowed_shadow_colors: Vec<Rgba>,
|
|
pub has_pill_radius: bool,
|
|
pub has_fonts: bool,
|
|
pub has_colors: bool,
|
|
pub has_radii: bool,
|
|
pub has_font_sizes: bool,
|
|
}
|
|
|
|
fn add_design_color(out: &mut DesignSystem, value: &str, label: Option<&str>) {
|
|
let Some(parsed) = parse_design_color(value) else {
|
|
return;
|
|
};
|
|
let key = color_key(&parsed);
|
|
let label = label
|
|
.map(|l| l.to_string())
|
|
.unwrap_or_else(|| css_color_label(value));
|
|
if let Some((_, entry)) = out.allowed_color_keys.iter_mut().find(|(k, _)| *k == key) {
|
|
entry.labels.push(label);
|
|
} else {
|
|
out.allowed_color_keys.push((
|
|
key,
|
|
AllowedColor {
|
|
color: parsed,
|
|
labels: vec![label],
|
|
},
|
|
));
|
|
}
|
|
}
|
|
|
|
fn add_color_object(out: &mut DesignSystem, colors: Option<&Value>, prefix: &str) {
|
|
let Some(Value::Object(colors)) = colors else {
|
|
return;
|
|
};
|
|
for (name, value) in colors {
|
|
if let Value::String(s) = value {
|
|
add_design_color(out, s, Some(&format!("{prefix}.{name}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_sidecar_colors(out: &mut DesignSystem, sidecar: Option<&Value>) {
|
|
let Some(color_meta) = sidecar
|
|
.and_then(|s| s.get("extensions"))
|
|
.and_then(|e| e.get("colorMeta"))
|
|
.and_then(|c| c.as_object())
|
|
else {
|
|
return;
|
|
};
|
|
for (name, meta) in color_meta {
|
|
let Some(meta) = meta.as_object() else {
|
|
continue;
|
|
};
|
|
if let Some(Value::String(c)) = meta.get("canonical") {
|
|
add_design_color(out, c, Some(&format!("sidecar.{name}")));
|
|
}
|
|
if let Some(Value::Array(ramp)) = meta.get("tonalRamp") {
|
|
for (index, value) in ramp.iter().enumerate() {
|
|
if let Value::String(v) = value {
|
|
add_design_color(out, v, Some(&format!("sidecar.{name}.tonalRamp[{index}]")));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_typography_fonts(out: &mut DesignSystem, typography: Option<&Value>) {
|
|
let Some(Value::Object(typography)) = typography else {
|
|
return;
|
|
};
|
|
for role in typography.values() {
|
|
let Some(role) = role.as_object() else {
|
|
continue;
|
|
};
|
|
let Some(Value::String(ff)) = role.get("fontFamily") else {
|
|
continue;
|
|
};
|
|
for font in split_font_stack(ff) {
|
|
if !is_generic_font(&font) && !out.allowed_fonts.contains(&font) {
|
|
out.allowed_fonts.push(font);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn value_to_string_nullish(v: Option<&Value>) -> String {
|
|
match v {
|
|
None | Some(Value::Null) => String::new(),
|
|
Some(v) => js_string(v),
|
|
}
|
|
}
|
|
|
|
fn add_font_size_step(out: &mut DesignSystem, raw: &str, fluid: bool) {
|
|
let text = js::to_lower_case(js::trim(raw));
|
|
if !FONT_SIZE_LITERAL_RE.is_match(&text) {
|
|
return;
|
|
}
|
|
let Some(px) = resolve_length_px(Some(&text), 16.0) else {
|
|
return;
|
|
};
|
|
if !px.is_finite() || px <= 0.0 {
|
|
return;
|
|
}
|
|
out.allowed_font_sizes.push(AllowedFontSize {
|
|
value: text,
|
|
px,
|
|
fluid,
|
|
});
|
|
}
|
|
|
|
re!(
|
|
CLAMP_RE,
|
|
format!("^{}\\({WS}*({ANY}+){WS}*\\)$", ci("clamp"))
|
|
);
|
|
|
|
fn parse_clamp_args(raw: &str) -> Option<Vec<String>> {
|
|
let m = CLAMP_RE.captures(js::trim(raw))?;
|
|
let args = split_top_level_args(&m[1]);
|
|
if args.len() == 3 {
|
|
Some(args)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn add_clamp_endpoints(out: &mut DesignSystem, raw: &str) -> bool {
|
|
let Some(args) = parse_clamp_args(raw) else {
|
|
return false;
|
|
};
|
|
add_font_size_step(out, &args[0], true);
|
|
add_font_size_step(out, &args[2], true);
|
|
true
|
|
}
|
|
|
|
fn split_top_level_args(s: &str) -> Vec<String> {
|
|
let mut args = Vec::new();
|
|
let mut depth = 0i32;
|
|
let mut current = String::new();
|
|
for ch in s.chars() {
|
|
if ch == '(' {
|
|
depth += 1;
|
|
} else if ch == ')' {
|
|
depth -= 1;
|
|
}
|
|
if ch == ',' && depth == 0 {
|
|
args.push(js::trim(¤t).to_string());
|
|
current.clear();
|
|
continue;
|
|
}
|
|
current.push(ch);
|
|
}
|
|
if !js::trim(¤t).is_empty() {
|
|
args.push(js::trim(¤t).to_string());
|
|
}
|
|
args
|
|
}
|
|
|
|
fn is_string_or_number(v: &Value) -> bool {
|
|
matches!(v, Value::String(_) | Value::Number(_))
|
|
}
|
|
|
|
fn add_typography_sizes(out: &mut DesignSystem, typography: Option<&Value>) {
|
|
let Some(Value::Object(typography)) = typography else {
|
|
return;
|
|
};
|
|
if let Some(Value::Object(scale)) = typography.get("scale") {
|
|
for value in scale.values() {
|
|
if !is_string_or_number(value) {
|
|
continue;
|
|
}
|
|
add_font_size_step(out, &js_string(value), false);
|
|
}
|
|
}
|
|
for (name, role) in typography {
|
|
if name == "scale" {
|
|
continue;
|
|
}
|
|
let Some(role) = role.as_object() else {
|
|
continue;
|
|
};
|
|
let raw = js::to_lower_case(js::trim(&value_to_string_nullish(role.get("fontSize"))));
|
|
if add_clamp_endpoints(out, &raw) {
|
|
continue;
|
|
}
|
|
add_font_size_step(out, &raw, false);
|
|
}
|
|
}
|
|
|
|
re!(PILL_NAME_RE, "(^|\\.)(full|pill|round|rounded-full)$");
|
|
re!(PILL_EXACT_RE, "^(full|pill|round|rounded-full)$");
|
|
re!(
|
|
PILL_ROLE_RE,
|
|
format!("^({}|{}|{})$", ci("full"), ci("pill"), ci("round"))
|
|
);
|
|
|
|
fn add_rounded_scale(out: &mut DesignSystem, rounded: Option<&Value>) {
|
|
let Some(Value::Object(rounded)) = rounded else {
|
|
return;
|
|
};
|
|
for (raw_name, value) in rounded {
|
|
let name = js::to_lower_case(unquote_yaml_key(raw_name));
|
|
add_rounded_token(out, &name, value);
|
|
}
|
|
}
|
|
|
|
fn add_rounded_token(out: &mut DesignSystem, name: &str, value: &Value) {
|
|
if !is_string_or_number(value) {
|
|
return;
|
|
}
|
|
let raw = js::trim(&js_string(value)).to_string();
|
|
if raw.is_empty() || VAR_RE.is_match(&raw) || raw.contains('%') {
|
|
return;
|
|
}
|
|
let Some(px) = resolve_length_px(Some(&raw), 16.0) else {
|
|
return;
|
|
};
|
|
if !px.is_finite() {
|
|
return;
|
|
}
|
|
out.allowed_radii.push(AllowedRadius {
|
|
name: name.to_string(),
|
|
value: raw,
|
|
px,
|
|
});
|
|
if PILL_NAME_RE.is_match(name) {
|
|
out.has_pill_radius = true;
|
|
}
|
|
}
|
|
|
|
fn add_sidecar_radii(out: &mut DesignSystem, sidecar: Option<&Value>) {
|
|
let Some(rounded_meta) = sidecar
|
|
.and_then(|s| s.get("extensions"))
|
|
.and_then(|e| e.get("roundedMeta"))
|
|
.and_then(|c| c.as_object())
|
|
else {
|
|
return;
|
|
};
|
|
for (raw_name, meta) in rounded_meta {
|
|
let name = js::to_lower_case(unquote_yaml_key(raw_name));
|
|
if is_string_or_number(meta) {
|
|
add_rounded_token(out, &format!("sidecar.{name}"), meta);
|
|
continue;
|
|
}
|
|
let Some(meta) = meta.as_object() else {
|
|
continue;
|
|
};
|
|
for key in ["canonical", "value"] {
|
|
if let Some(v) = meta.get(key) {
|
|
if is_string_or_number(v) {
|
|
add_rounded_token(out, &format!("sidecar.{name}.{key}"), v);
|
|
}
|
|
}
|
|
}
|
|
for key in ["values", "aliases"] {
|
|
let Some(Value::Array(list)) = meta.get(key) else {
|
|
continue;
|
|
};
|
|
for (index, value) in list.iter().enumerate() {
|
|
add_rounded_token(out, &format!("sidecar.{name}.{key}[{index}]"), value);
|
|
}
|
|
}
|
|
let role = value_to_string_nullish(meta.get("role"));
|
|
if PILL_EXACT_RE.is_match(&name) || PILL_ROLE_RE.is_match(&role) {
|
|
out.has_pill_radius = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_sidecar_shadows(out: &mut DesignSystem, sidecar: Option<&Value>) {
|
|
let Some(Value::Array(shadows)) = sidecar
|
|
.and_then(|s| s.get("extensions"))
|
|
.and_then(|e| e.get("shadows"))
|
|
else {
|
|
return;
|
|
};
|
|
for entry in shadows {
|
|
let Some(Value::String(value)) = entry.get("value") else {
|
|
continue;
|
|
};
|
|
for m in CSS_COLOR_RE.find_iter(value) {
|
|
if let Some(parsed) = parse_design_color(m.as_str()) {
|
|
out.allowed_shadow_colors.push(parsed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// JS: design-system.mjs#normalizeDesignSystem
|
|
pub fn normalize_design_system(
|
|
frontmatter: Option<&Map<String, Value>>,
|
|
sidecar: Option<&Value>,
|
|
source_path: Option<&str>,
|
|
sidecar_path: Option<&str>,
|
|
md_newer_than_json: bool,
|
|
) -> DesignSystem {
|
|
let empty = Map::new();
|
|
let frontmatter = frontmatter.unwrap_or(&empty);
|
|
let mut out = DesignSystem {
|
|
present: true,
|
|
source_path: source_path.map(|s| s.to_string()),
|
|
sidecar_path: sidecar_path.map(|s| s.to_string()),
|
|
md_newer_than_json,
|
|
..Default::default()
|
|
};
|
|
add_typography_fonts(&mut out, frontmatter.get("typography"));
|
|
add_typography_sizes(&mut out, frontmatter.get("typography"));
|
|
add_color_object(&mut out, frontmatter.get("colors"), "colors");
|
|
add_sidecar_colors(&mut out, sidecar);
|
|
add_rounded_scale(&mut out, frontmatter.get("rounded"));
|
|
add_sidecar_radii(&mut out, sidecar);
|
|
add_sidecar_shadows(&mut out, sidecar);
|
|
out.has_fonts = !out.allowed_fonts.is_empty();
|
|
out.has_colors = !out.allowed_color_keys.is_empty();
|
|
out.has_radii = !out.allowed_radii.is_empty();
|
|
out.has_font_sizes = out.allowed_font_sizes.iter().any(|e| !e.fluid);
|
|
out
|
|
}
|
|
|
|
fn mtime_ms(p: &str) -> Option<f64> {
|
|
let meta = std::fs::metadata(p).ok()?;
|
|
let m = meta.modified().ok()?;
|
|
let d = m.duration_since(std::time::UNIX_EPOCH).ok()?;
|
|
Some(d.as_secs_f64() * 1000.0)
|
|
}
|
|
|
|
/// JS: design-system.mjs#loadDesignSystemForCwd
|
|
pub fn load_design_system_for_cwd(cwd: &str) -> Option<DesignSystem> {
|
|
let md = resolve_design_md_path(cwd)?;
|
|
let md_stat = mtime_ms(&md.path);
|
|
let text = read_text(&md.path)?;
|
|
let frontmatter = parse_frontmatter(&text)?;
|
|
let sidecar_path = resolve_design_sidecar_path(cwd, &md.context_dir);
|
|
let sidecar = sidecar_path.as_deref().and_then(read_json);
|
|
let sidecar_stat = sidecar_path.as_deref().and_then(mtime_ms);
|
|
let md_newer = matches!((md_stat, sidecar_stat), (Some(m), Some(s)) if m > s + 1000.0);
|
|
let mut ds = normalize_design_system(
|
|
Some(&frontmatter),
|
|
sidecar.as_ref(),
|
|
Some(&md.path),
|
|
sidecar_path.as_deref(),
|
|
md_newer,
|
|
);
|
|
ds.declared_selectors = declared_component_selectors(&text);
|
|
Some(ds)
|
|
}
|
|
|
|
/// JS `designSystemStartDir(targetPath, cwd)`.
|
|
pub fn design_system_start_dir(target_path: &str, cwd: &str) -> String {
|
|
let abs = if jsp::is_absolute(target_path) {
|
|
target_path.to_string()
|
|
} else {
|
|
jsp::resolve(cwd, &[target_path])
|
|
};
|
|
match std::fs::metadata(&abs) {
|
|
Ok(m) => {
|
|
if m.is_dir() {
|
|
abs
|
|
} else {
|
|
jsp::dirname(&abs)
|
|
}
|
|
}
|
|
Err(_) => {
|
|
if !jsp::extname(&abs).is_empty() {
|
|
jsp::dirname(&abs)
|
|
} else {
|
|
abs
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `{ dir, hasDesign }` from `findDesignRoot`.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct DesignRoot {
|
|
pub dir: String,
|
|
pub has_design: bool,
|
|
}
|
|
|
|
/// JS: design-system.mjs#readWorkspacePatternGroups — Impeccable projectRoots
|
|
/// govern any path they match (positive or negated); package-manager globs
|
|
/// only apply to paths the Impeccable group does not match.
|
|
fn read_workspace_pattern_groups(dir: &str) -> (Vec<String>, Vec<String>) {
|
|
let mut impeccable: Vec<String> = Vec::new();
|
|
for name in ["config.json", "config.local.json"] {
|
|
let roots = read_json(&jsp::join(&[dir, ".impeccable", name]))
|
|
.and_then(|v| v.get("projectRoots").cloned());
|
|
if let Some(Value::Array(roots)) = roots {
|
|
for entry in roots {
|
|
if let Value::String(s) = entry {
|
|
let t = js::trim(&s);
|
|
if !t.is_empty() {
|
|
impeccable.push(t.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut pkg: Vec<String> = Vec::new();
|
|
let workspaces = read_json(&jsp::join(&[dir, "package.json"])).and_then(|v| v.get("workspaces").cloned());
|
|
match &workspaces {
|
|
Some(Value::Array(ws)) => pkg.extend(ws.iter().map(js_string)),
|
|
Some(other) => {
|
|
if let Some(Value::Array(ws)) = other.get("packages") {
|
|
pkg.extend(ws.iter().map(js_string));
|
|
}
|
|
}
|
|
None => {}
|
|
}
|
|
if let Some(Value::Array(ws)) = read_json(&jsp::join(&[dir, "lerna.json"])).and_then(|v| v.get("packages").cloned()) {
|
|
pkg.extend(ws.iter().map(js_string));
|
|
}
|
|
if let Some(text) = read_text(&jsp::join(&[dir, "pnpm-workspace.yaml"])) {
|
|
let mut in_packages = false;
|
|
for line in text.split("\r\n").flat_map(|l| l.split('\n')) {
|
|
let stripped = strip_inline_yaml_comment(line);
|
|
let trimmed = js::trim(&stripped);
|
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some(caps) = PNPM_FLOW_RE.captures(trimmed) {
|
|
for entry in caps.get(1).map(|m| m.as_str()).unwrap_or("").split(',') {
|
|
let e = EDGE_QUOTE_RE.replace_all(js::trim(entry), "").into_owned();
|
|
if !e.is_empty() {
|
|
pkg.push(e);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if PNPM_PACKAGES_RE.is_match(trimmed) {
|
|
in_packages = true;
|
|
continue;
|
|
}
|
|
if !in_packages {
|
|
continue;
|
|
}
|
|
if let Some(caps) = PNPM_ITEM_RE.captures(trimmed) {
|
|
pkg.push(EDGE_QUOTE_RE.replace_all(js::trim(caps.get(1).map(|m| m.as_str()).unwrap_or("")), "").into_owned());
|
|
} else if PNPM_KEY_RE.is_match(trimmed) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
(impeccable, pkg)
|
|
}
|
|
|
|
/// JS: design-system.mjs#readWorkspacePatterns
|
|
fn read_workspace_patterns(dir: &str) -> Vec<String> {
|
|
let (mut a, mut b) = read_workspace_pattern_groups(dir);
|
|
a.append(&mut b);
|
|
a
|
|
}
|
|
|
|
const MONOREPO_MARKER_FILES: &[&str] = &["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"];
|
|
const MONOREPO_FALLBACK_PROJECT_DIRS: &[&str] = &["apps", "packages"];
|
|
|
|
re!(PNPM_FLOW_RE, format!("^packages:{WS}*\\[(.*)\\]{WS}*$"));
|
|
re!(PNPM_PACKAGES_RE, format!("^packages:{WS}*$"));
|
|
re!(PNPM_ITEM_RE, format!("^-{WS}*(.+)$"));
|
|
re!(PNPM_KEY_RE, format!("^[A-Za-z0-9_-]+:{WS}*"));
|
|
|
|
/// JS: design-system.mjs#isMonorepoRoot
|
|
fn is_monorepo_root(dir: &str) -> bool {
|
|
if read_workspace_patterns(dir).iter().any(|p| !js::trim(p).starts_with('!')) {
|
|
return true;
|
|
}
|
|
if !MONOREPO_MARKER_FILES.iter().any(|f| exists(&jsp::join(&[dir, f]))) {
|
|
return false;
|
|
}
|
|
MONOREPO_FALLBACK_PROJECT_DIRS.iter().any(|name| {
|
|
std::fs::read_dir(jsp::join(&[dir, name]))
|
|
.map(|entries| {
|
|
entries
|
|
.flatten()
|
|
.any(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
|
})
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
/// JS: design-system.mjs#monorepoOwnsPath > normalizeWorkspacePattern
|
|
fn normalize_workspace_pattern(pattern: &str) -> String {
|
|
let mut s = EDGE_QUOTE_RE.replace_all(js::trim(pattern), "").into_owned();
|
|
if let Some(rest) = s.strip_prefix("./") {
|
|
s = rest.to_string();
|
|
}
|
|
while s.ends_with('/') {
|
|
s.pop();
|
|
}
|
|
s
|
|
}
|
|
|
|
/// JS: design-system.mjs#monorepoOwnsPath > segmentMatches
|
|
fn segment_matches(pattern_segment: &str, rel_segment: &str) -> bool {
|
|
if pattern_segment == "*" {
|
|
return true;
|
|
}
|
|
if !pattern_segment.contains('*') {
|
|
return pattern_segment == rel_segment;
|
|
}
|
|
let escaped = regex::escape(pattern_segment).replace("\\*", "[^/]*");
|
|
Regex::new(&format!("^{}$", escaped)).map(|re| re.is_match(rel_segment)).unwrap_or(false)
|
|
}
|
|
|
|
/// JS: design-system.mjs#monorepoOwnsPath > matchGlobSegments
|
|
fn match_glob_segments(pattern_segments: &[&str], rel_segments: &[&str]) -> bool {
|
|
fn rec(pattern_segments: &[&str], rel_segments: &[&str], pi: usize, ri: usize) -> bool {
|
|
if pi == pattern_segments.len() {
|
|
return ri == rel_segments.len();
|
|
}
|
|
if pattern_segments[pi] == "**" {
|
|
if pi == pattern_segments.len() - 1 {
|
|
return true;
|
|
}
|
|
for k in ri..=rel_segments.len() {
|
|
if rec(pattern_segments, rel_segments, pi + 1, k) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
if ri >= rel_segments.len() {
|
|
return false;
|
|
}
|
|
if !segment_matches(pattern_segments[pi], rel_segments[ri]) {
|
|
return false;
|
|
}
|
|
rec(pattern_segments, rel_segments, pi + 1, ri + 1)
|
|
}
|
|
rec(pattern_segments, rel_segments, 0, 0)
|
|
}
|
|
|
|
/// JS: design-system.mjs#monorepoOwnsPath
|
|
fn monorepo_owns_path(root: &str, boundary_dir: &str) -> bool {
|
|
let rel = jsp::relative("/", root, boundary_dir);
|
|
if rel.is_empty() || rel.starts_with("..") || jsp::is_absolute(&rel) {
|
|
return false;
|
|
}
|
|
let rel_segments: Vec<&str> = rel.split(jsp::SEP_CHAR).filter(|s| !s.is_empty()).collect();
|
|
|
|
// Negations like !packages/excluded must also cover nested dirs under
|
|
// that path.
|
|
let matches_negation = |pattern: &str| -> bool {
|
|
let normalized = normalize_workspace_pattern(pattern);
|
|
let pattern_segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
|
|
if pattern_segments.is_empty() {
|
|
return false;
|
|
}
|
|
if pattern_segments.contains(&"**") {
|
|
return match_glob_segments(&pattern_segments, &rel_segments);
|
|
}
|
|
if rel_segments.len() < pattern_segments.len() {
|
|
return false;
|
|
}
|
|
pattern_segments
|
|
.iter()
|
|
.zip(rel_segments.iter())
|
|
.all(|(p, r)| segment_matches(p, r))
|
|
};
|
|
|
|
// Positive globs identify workspace packages at exact depth (`*` is a
|
|
// direct child). A nested package.json under that package is still owned:
|
|
// the ancestor directory of glob length must itself be a package.
|
|
let positive_owns = |pattern: &str| -> bool {
|
|
let normalized = normalize_workspace_pattern(pattern);
|
|
let pattern_segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
|
|
if pattern_segments.is_empty() {
|
|
return false;
|
|
}
|
|
if pattern_segments.contains(&"**") {
|
|
return match_glob_segments(&pattern_segments, &rel_segments);
|
|
}
|
|
if rel_segments.len() < pattern_segments.len() {
|
|
return false;
|
|
}
|
|
if !pattern_segments.iter().zip(rel_segments.iter()).all(|(p, r)| segment_matches(p, r)) {
|
|
return false;
|
|
}
|
|
if rel_segments.len() == pattern_segments.len() {
|
|
return true;
|
|
}
|
|
let mut parts: Vec<&str> = vec![root];
|
|
parts.extend(&rel_segments[..pattern_segments.len()]);
|
|
let ancestor_dir = jsp::join(&parts);
|
|
exists(&jsp::join(&[&ancestor_dir, "package.json"]))
|
|
};
|
|
|
|
let group_owns = |raw_patterns: &[String]| -> Option<bool> {
|
|
let patterns: Vec<String> = raw_patterns
|
|
.iter()
|
|
.map(|p| normalize_workspace_pattern(p))
|
|
.filter(|p| !p.is_empty())
|
|
.collect();
|
|
if patterns.is_empty() {
|
|
return None;
|
|
}
|
|
let excluded = patterns
|
|
.iter()
|
|
.any(|pattern| pattern.starts_with('!') && matches_negation(&pattern[1..]));
|
|
let included = patterns
|
|
.iter()
|
|
.filter(|pattern| !pattern.starts_with('!'))
|
|
.any(|pattern| positive_owns(pattern));
|
|
if !excluded && !included {
|
|
return None;
|
|
}
|
|
if excluded {
|
|
return Some(false);
|
|
}
|
|
Some(true)
|
|
};
|
|
|
|
let (impeccable, pkg) = read_workspace_pattern_groups(root);
|
|
if let Some(from_impeccable) = group_owns(&impeccable) {
|
|
return from_impeccable;
|
|
}
|
|
if let Some(from_pkg) = group_owns(&pkg) {
|
|
return from_pkg;
|
|
}
|
|
if impeccable
|
|
.iter()
|
|
.chain(pkg.iter())
|
|
.any(|pattern| !normalize_workspace_pattern(pattern).starts_with('!'))
|
|
{
|
|
return false;
|
|
}
|
|
rel_segments.len() >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.contains(&rel_segments[0])
|
|
}
|
|
|
|
/// JS: design-system.mjs#homeDirForms — both forms of the home directory. The
|
|
/// walk compares path strings, and a symlinked home (e.g. /home -> /var/home)
|
|
/// never string-matches the physical paths a cwd-resolved target produces,
|
|
/// which would let the post-boundary walk sail through $HOME and inherit
|
|
/// from it.
|
|
fn home_dir_forms(cwd: &str, home: &str) -> Vec<String> {
|
|
let home_dir = jsp::resolve(cwd, &[home]);
|
|
let mut forms = vec![home_dir.clone()];
|
|
if let Ok(real) = std::fs::canonicalize(&home_dir) {
|
|
let real = real.to_string_lossy().into_owned();
|
|
let real = real.strip_prefix("\\\\?\\").map(|s| s.to_string()).unwrap_or(real);
|
|
if !forms.contains(&real) {
|
|
forms.push(real);
|
|
}
|
|
}
|
|
forms
|
|
}
|
|
|
|
/// JS: design-system.mjs#findDesignRoot. `home` is `os.homedir()`.
|
|
///
|
|
/// A directory carrying a project marker but no DESIGN.md is a project
|
|
/// BOUNDARY. A nested package.json inherits the ancestor DESIGN.md only when
|
|
/// that ancestor's workspace declarations include the path (negations win; a
|
|
/// nested package under a matched workspace still inherits). Marker-only
|
|
/// roots (turbo/nx/lerna/pnpm with no globs) still own apps/<name> and
|
|
/// packages/<name>. A stray nested package that matches no glob does not
|
|
/// inherit, and a nested separate repository (.git with no workspace
|
|
/// declaration) still inherits nothing (issue #570).
|
|
pub fn find_design_root(start_dir: &str, cwd: &str, home: &str) -> Option<DesignRoot> {
|
|
let mut dir = jsp::resolve(cwd, &[start_dir]);
|
|
let home_dirs = home_dir_forms(cwd, home);
|
|
let mut boundary: Option<DesignRoot> = None;
|
|
loop {
|
|
if boundary.is_none() && resolve_design_md_path(&dir).is_some() {
|
|
return Some(DesignRoot { dir, has_design: true });
|
|
}
|
|
if let Some(b) = &boundary {
|
|
// Past the boundary the walk only looks for the monorepo root
|
|
// that owns the workspace path. Monorepo-root before .git, same
|
|
// order as context.mjs: a workspace root carrying its own .git is
|
|
// still recognized, while a .git that declares no workspaces is a
|
|
// separate repository and stops the walk with nothing inherited.
|
|
// The home directory is never an owning root.
|
|
if !home_dirs.contains(&dir) && is_monorepo_root(&dir) {
|
|
if monorepo_owns_path(&dir, &b.dir) {
|
|
let has_design = resolve_design_md_path(&dir).is_some();
|
|
return Some(DesignRoot { dir, has_design });
|
|
}
|
|
return boundary;
|
|
}
|
|
if exists(&jsp::join(&[&dir, ".git"])) {
|
|
return boundary;
|
|
}
|
|
} else if PROJECT_ROOT_MARKERS.iter().any(|marker| exists(&jsp::join(&[&dir, marker]))) {
|
|
boundary = Some(DesignRoot { dir: dir.clone(), has_design: false });
|
|
// A boundary that is itself a monorepo root, or a separate
|
|
// repository with its own .git, inherits nothing from above.
|
|
if is_monorepo_root(&dir) || exists(&jsp::join(&[&dir, ".git"])) {
|
|
return boundary;
|
|
}
|
|
}
|
|
if home_dirs.contains(&dir) {
|
|
return boundary;
|
|
}
|
|
let parent = jsp::dirname(&dir);
|
|
if parent == dir {
|
|
return boundary;
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/// Memo for `load_design_system_for_target`, keyed by design root.
|
|
pub type DesignSystemCache = HashMap<String, Option<Rc<DesignSystem>>>;
|
|
|
|
/// JS: design-system.mjs#loadDesignSystemForTarget
|
|
pub fn load_design_system_for_target(
|
|
target_path: &str,
|
|
cache: Option<&mut DesignSystemCache>,
|
|
cwd: &str,
|
|
home: &str,
|
|
) -> Option<Rc<DesignSystem>> {
|
|
let start_dir = design_system_start_dir(target_path, cwd);
|
|
let found = find_design_root(&start_dir, cwd, home);
|
|
let key = match &found {
|
|
Some(f) => format!("root:{}", f.dir),
|
|
None => "\0none".to_string(),
|
|
};
|
|
if let Some(cache) = &cache {
|
|
if let Some(hit) = cache.get(&key) {
|
|
return hit.clone();
|
|
}
|
|
}
|
|
let loaded = match &found {
|
|
Some(f) if f.has_design => load_design_system_for_cwd(&f.dir).map(Rc::new),
|
|
_ => None,
|
|
};
|
|
if let Some(cache) = cache {
|
|
cache.insert(key, loaded.clone());
|
|
}
|
|
loaded
|
|
}
|
|
|
|
// ─── Allowlist predicates ────────────────────────────────────────────────────
|
|
|
|
/// JS: design-system.mjs#isAllowedFont
|
|
pub fn is_allowed_font(font: &str, ds: Option<&DesignSystem>) -> bool {
|
|
if font.is_empty() || is_generic_font(font) {
|
|
return true;
|
|
}
|
|
match ds {
|
|
Some(ds) if ds.has_fonts => ds.allowed_fonts.iter().any(|f| f == font),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
/// JS: design-system.mjs#isAllowedColorRaw
|
|
pub fn is_allowed_color_raw(raw: &str, ds: Option<&DesignSystem>) -> bool {
|
|
let Some(ds) = ds.filter(|d| d.has_colors) else {
|
|
return true;
|
|
};
|
|
let text = js::to_lower_case(js::trim(raw));
|
|
if text.is_empty()
|
|
|| text == "transparent"
|
|
|| text == "currentcolor"
|
|
|| text == "inherit"
|
|
|| text == "initial"
|
|
{
|
|
return true;
|
|
}
|
|
if text.contains("var(") {
|
|
return true;
|
|
}
|
|
let Some(parsed) = parse_design_color(&text) else {
|
|
return true;
|
|
};
|
|
if parsed.alpha_or_one() <= 0.05 {
|
|
return true;
|
|
}
|
|
ds.allowed_color_keys
|
|
.iter()
|
|
.any(|(_, entry)| colors_close(&parsed, &entry.color))
|
|
}
|
|
|
|
/// JS: design-system.mjs#isAllowedShadowColorRaw
|
|
pub fn is_allowed_shadow_color_raw(raw: &str, ds: Option<&DesignSystem>) -> bool {
|
|
let Some(ds) = ds.filter(|d| !d.allowed_shadow_colors.is_empty()) else {
|
|
return false;
|
|
};
|
|
let Some(parsed) = parse_design_color(&js::to_lower_case(js::trim(raw))) else {
|
|
return false;
|
|
};
|
|
ds.allowed_shadow_colors.iter().any(|entry| {
|
|
colors_close(&parsed, entry)
|
|
&& (parsed.alpha_or_one() - entry.alpha_or_one()).abs() <= SHADOW_ALPHA_TOLERANCE
|
|
})
|
|
}
|
|
|
|
/// JS: design-system.mjs#isAllowedRadiusRaw
|
|
pub fn is_allowed_radius_raw(raw: &str, ds: Option<&DesignSystem>) -> bool {
|
|
let Some(ds) = ds.filter(|d| d.has_radii) else {
|
|
return true;
|
|
};
|
|
let text = js::to_lower_case(js::trim(raw));
|
|
if text.is_empty() || text == "0" || text == "none" || text == "initial" || text == "inherit" {
|
|
return true;
|
|
}
|
|
if text.contains("var(") || text.contains('%') {
|
|
return true;
|
|
}
|
|
let Some(px) = resolve_length_px(Some(&text), 16.0) else {
|
|
return true;
|
|
};
|
|
if !px.is_finite() || px <= RADIUS_TOLERANCE_PX {
|
|
return true;
|
|
}
|
|
if ds.has_pill_radius && px >= 99.0 {
|
|
return true;
|
|
}
|
|
ds.allowed_radii
|
|
.iter()
|
|
.any(|entry| (entry.px - px).abs() <= RADIUS_TOLERANCE_PX)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
enum StepStatus {
|
|
Unjudgeable,
|
|
OnRamp,
|
|
OffRamp,
|
|
}
|
|
|
|
fn font_size_step_status(raw: &str, ds: &DesignSystem) -> StepStatus {
|
|
let text = js::to_lower_case(js::trim(raw));
|
|
if !FONT_SIZE_LITERAL_RE.is_match(&text) {
|
|
return StepStatus::Unjudgeable;
|
|
}
|
|
let Some(px) = resolve_length_px(Some(&text), 16.0) else {
|
|
return StepStatus::Unjudgeable;
|
|
};
|
|
if !px.is_finite() || px <= 0.0 {
|
|
return StepStatus::Unjudgeable;
|
|
}
|
|
if ds
|
|
.allowed_font_sizes
|
|
.iter()
|
|
.any(|entry| (entry.px - px).abs() <= FONT_SIZE_TOLERANCE_PX)
|
|
{
|
|
StepStatus::OnRamp
|
|
} else {
|
|
StepStatus::OffRamp
|
|
}
|
|
}
|
|
|
|
/// JS: design-system.mjs#offRampClampEndpoints. `None` when `raw` is not a
|
|
/// fluid value (or the system has no ramp).
|
|
pub fn off_ramp_clamp_endpoints(raw: &str, ds: Option<&DesignSystem>) -> Option<Vec<String>> {
|
|
let ds = ds.filter(|d| d.has_font_sizes)?;
|
|
let cleaned = IMPORTANT_TAIL_RE.replace(js::trim(raw), "");
|
|
let args = parse_clamp_args(&cleaned)?;
|
|
Some(
|
|
[args[0].clone(), args[2].clone()]
|
|
.into_iter()
|
|
.filter(|endpoint| font_size_step_status(endpoint, ds) == StepStatus::OffRamp)
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
/// JS: design-system.mjs#isAllowedFontSizeRaw
|
|
pub fn is_allowed_font_size_raw(raw: &str, ds: Option<&DesignSystem>) -> bool {
|
|
let Some(ds) = ds.filter(|d| d.has_font_sizes) else {
|
|
return true;
|
|
};
|
|
let text = js::to_lower_case(js::trim(raw));
|
|
let text = IMPORTANT_TAIL_CS_RE.replace(&text, "");
|
|
if let Some(off) = off_ramp_clamp_endpoints(&text, Some(ds)) {
|
|
return off.is_empty();
|
|
}
|
|
font_size_step_status(&text, ds) != StepStatus::OffRamp
|
|
}
|
|
|
|
fn line_looks_commented(line: &str) -> bool {
|
|
let t = js::trim(line);
|
|
t.starts_with("//") || t.starts_with("/*") || t.starts_with('*') || t.starts_with("<!--")
|
|
}
|
|
|
|
re!(
|
|
STYLE_CONTEXT_RE,
|
|
format!(
|
|
"(?:^|[{{{WS_CHARS};\"'`(,])(?:{color}|{background}(?:-{color}|-{image})?|{border}(?:-(?:{top}|{right}|{bottom}|{left}))?(?:-{color})?|{outline}(?:-{color})?|{box_shadow}|{text_shadow}|{fill}|{stroke}){WS}*:{WS}*[^;{{}}\"'`]*",
|
|
WS_CHARS = impeccable_core::js::WS_CHARS,
|
|
color = ci("color"),
|
|
background = ci("background"),
|
|
image = ci("image"),
|
|
border = ci("border"),
|
|
top = ci("top"),
|
|
right = ci("right"),
|
|
bottom = ci("bottom"),
|
|
left = ci("left"),
|
|
outline = ci("outline"),
|
|
box_shadow = ci("box-shadow"),
|
|
text_shadow = ci("text-shadow"),
|
|
fill = ci("fill"),
|
|
stroke = ci("stroke")
|
|
)
|
|
);
|
|
re!(
|
|
CSS_FUNCTION_CONTEXT_RE,
|
|
format!(
|
|
"(?:{}|{}|{}|{})\\([^)]*$",
|
|
ci("linear-gradient"),
|
|
ci("radial-gradient"),
|
|
ci("conic-gradient"),
|
|
ci("color-mix")
|
|
)
|
|
);
|
|
re!(
|
|
JS_COLOR_KEY_CONTEXT_RE,
|
|
format!(
|
|
"(?:^|[,{{]{WS}*)(?:{color}|{background}|{backgroundColor}|{borderColor}|{outlineColor}|{fill}|{stroke}|{boxShadow}|{textShadow}){WS}*[:=]{WS}*[\"'`]?[^\"'`,}}]*",
|
|
color = ci("color"),
|
|
background = ci("background"),
|
|
backgroundColor = ci("backgroundColor"),
|
|
borderColor = ci("borderColor"),
|
|
outlineColor = ci("outlineColor"),
|
|
fill = ci("fill"),
|
|
stroke = ci("stroke"),
|
|
boxShadow = ci("boxShadow"),
|
|
textShadow = ci("textShadow")
|
|
)
|
|
);
|
|
|
|
fn is_probably_color_literal(line: &str, start: usize, raw: &str) -> bool {
|
|
if is_inside_css_attribute_selector(line, start) {
|
|
return false;
|
|
}
|
|
let before = &line[..start];
|
|
let after = &line[start + raw.len()..];
|
|
if raw.starts_with('#') {
|
|
if before.ends_with('&') {
|
|
return false;
|
|
}
|
|
// JS `before.match(/\S(?=\s*$)/)?.[0]` / `after.match(/^\s*(\S)/)?.[1]`.
|
|
let prev_non_space = before.trim_end_matches(js::is_js_whitespace).chars().last();
|
|
let next_non_space = after
|
|
.trim_start_matches(js::is_js_whitespace)
|
|
.chars()
|
|
.next();
|
|
if prev_non_space == Some('>') && next_non_space == Some('<') {
|
|
return false;
|
|
}
|
|
}
|
|
STYLE_CONTEXT_RE.is_match(before)
|
|
|| CSS_FUNCTION_CONTEXT_RE.is_match(before)
|
|
|| JS_COLOR_KEY_CONTEXT_RE.is_match(before)
|
|
}
|
|
|
|
const QUOTED_STRING_SRC: &str = r#""[^"]*"|'[^']*'"#;
|
|
static INTERPOLATION_SRC: Lazy<String> = Lazy::new(|| {
|
|
format!(
|
|
"\\$\\{{(?:{q}|\\{{(?:{q}|[^{{}}\"'`])*\\}}|[^{{}}\"'`])*\\}}",
|
|
q = QUOTED_STRING_SRC
|
|
)
|
|
});
|
|
static SHADOW_CSS_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
|
|
Regex::new(&format!(
|
|
"(?:^|[{{{WS_CHARS};\"'`(,])(?:{box_shadow}|{text_shadow}){WS}*:{WS}*(?:{interp}|[^;{{}}\"'`])*$",
|
|
WS_CHARS = impeccable_core::js::WS_CHARS,
|
|
WS = WS,
|
|
box_shadow = ci("box-shadow"),
|
|
text_shadow = ci("text-shadow"),
|
|
interp = *INTERPOLATION_SRC
|
|
))
|
|
.unwrap()
|
|
});
|
|
static SHADOW_JS_CONTEXT_RE: Lazy<Regex> = Lazy::new(|| {
|
|
Regex::new(&format!(
|
|
"(?:^|[,{{]{WS}*)(?:{box_shadow}|{text_shadow}){WS}*[:=]{WS}*[\"'`]?(?:{interp}|[^\"'`}}])*$",
|
|
WS = WS,
|
|
box_shadow = ci("boxShadow"),
|
|
text_shadow = ci("textShadow"),
|
|
interp = *INTERPOLATION_SRC
|
|
))
|
|
.unwrap()
|
|
});
|
|
|
|
fn is_shadow_property_context(line: &str, start: usize) -> bool {
|
|
let before = &line[..start];
|
|
SHADOW_CSS_CONTEXT_RE.is_match(before) || SHADOW_JS_CONTEXT_RE.is_match(before)
|
|
}
|
|
|
|
fn is_inside_css_attribute_selector(line: &str, index: usize) -> bool {
|
|
let before = &line[..index];
|
|
let Some(last_open) = before.rfind('[') else {
|
|
return false;
|
|
};
|
|
if let Some(last_close) = before.rfind(']') {
|
|
if last_close > last_open {
|
|
return false;
|
|
}
|
|
}
|
|
let after = &line[index..];
|
|
let close = after.find(']');
|
|
let block = after.find('{');
|
|
match close {
|
|
None => false,
|
|
Some(c) => block.map(|b| c < b).unwrap_or(true),
|
|
}
|
|
}
|
|
|
|
/// JS `makeDesignFinding(id, filePath, snippet, line, { ignoreValue })`.
|
|
pub fn make_design_finding(
|
|
id: &str,
|
|
file_path: &str,
|
|
snippet: &str,
|
|
line: f64,
|
|
ignore_value: &str,
|
|
) -> Finding {
|
|
let mut f = finding(id, file_path, snippet, line);
|
|
f.extras.insert(
|
|
"ignoreValue".into(),
|
|
Value::String(ignore_value.to_string()),
|
|
);
|
|
f
|
|
}
|
|
|
|
fn decode_google_family(value: &str) -> String {
|
|
let family = value.split(':').next().unwrap_or("").replace('+', " ");
|
|
crate::config::decode_uri_component(&family)
|
|
}
|
|
|
|
/// JS `primary.replace(/\b\w/g, ch => ch.toUpperCase())`.
|
|
fn title_case_ascii_words(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut prev_word = false;
|
|
for c in s.chars() {
|
|
let is_word = c.is_ascii_alphanumeric() || c == '_';
|
|
if is_word && !prev_word {
|
|
out.push(c.to_ascii_uppercase());
|
|
} else {
|
|
out.push(c);
|
|
}
|
|
prev_word = is_word;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn check_font_stack(
|
|
stack: &str,
|
|
file_path: &str,
|
|
line: f64,
|
|
ds: &DesignSystem,
|
|
context: &str,
|
|
) -> Vec<Finding> {
|
|
let primary = primary_font(stack);
|
|
if primary.is_empty() || is_allowed_font(&primary, Some(ds)) {
|
|
return vec![];
|
|
}
|
|
let display = title_case_ascii_words(&primary);
|
|
vec![make_design_finding(
|
|
"design-system-font",
|
|
file_path,
|
|
&format!("{context}: {display} is not declared in DESIGN.md typography"),
|
|
line,
|
|
&display,
|
|
)]
|
|
}
|
|
|
|
re!(SLASH_WS_RE, format!("{WS}*/{WS}*"));
|
|
|
|
/// JS: design-system.mjs#extractRadiusTokens
|
|
pub fn extract_radius_tokens(value: &str) -> Vec<String> {
|
|
let replaced = SLASH_WS_RE.replace_all(value, " ");
|
|
WS_RUN_RE
|
|
.split(&replaced)
|
|
// JS-PARITY: extractRadiusTokens strips a trailing `)+` (regex /\)+$/)
|
|
// after trim so a var() fallback's closing paren (`8px)`) is not parsed
|
|
// as unitless 8rem. (upstream 1bcdf80f / #687)
|
|
.map(|t| js::trim(t).trim_end_matches(')').to_string())
|
|
.filter(|t| !t.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn check_radius_value(
|
|
value: &str,
|
|
file_path: &str,
|
|
line: f64,
|
|
ds: &DesignSystem,
|
|
context: &str,
|
|
) -> Vec<Finding> {
|
|
let mut findings = Vec::new();
|
|
for token in extract_radius_tokens(value) {
|
|
if is_allowed_radius_raw(&token, Some(ds)) {
|
|
continue;
|
|
}
|
|
findings.push(make_design_finding(
|
|
"design-system-radius",
|
|
file_path,
|
|
&format!("{context}: {token} is outside the DESIGN.md rounded scale"),
|
|
line,
|
|
&token,
|
|
));
|
|
}
|
|
findings
|
|
}
|
|
|
|
fn check_font_size_value(
|
|
value: &str,
|
|
file_path: &str,
|
|
line: f64,
|
|
ds: &DesignSystem,
|
|
context: &str,
|
|
) -> Vec<Finding> {
|
|
let token = js::trim(value).to_string();
|
|
if is_allowed_font_size_raw(&token, Some(ds)) {
|
|
return vec![];
|
|
}
|
|
let off = off_ramp_clamp_endpoints(&token, Some(ds)).unwrap_or_default();
|
|
if !off.is_empty() {
|
|
let plural = if off.len() > 1 { "s" } else { "" };
|
|
return vec![make_design_finding(
|
|
"design-system-font-size",
|
|
file_path,
|
|
&format!(
|
|
"{context}: {token} has fluid endpoint{plural} {} off the DESIGN.md type ramp",
|
|
off.join(" and ")
|
|
),
|
|
line,
|
|
&off[0],
|
|
)];
|
|
}
|
|
let ignore_value = js::trim(&IMPORTANT_TAIL_RE.replace(&token, "")).to_string();
|
|
vec![make_design_finding(
|
|
"design-system-font-size",
|
|
file_path,
|
|
&format!("{context}: {token} is off the DESIGN.md type ramp"),
|
|
line,
|
|
&ignore_value,
|
|
)]
|
|
}
|
|
|
|
/// JS: design-system.mjs#checkSourceDesignSystem
|
|
pub fn check_source_design_system(
|
|
content: &str,
|
|
file_path: &str,
|
|
ds: Option<&DesignSystem>,
|
|
) -> Vec<Finding> {
|
|
let Some(ds) = ds.filter(|d| d.present) else {
|
|
return vec![];
|
|
};
|
|
let mut findings = Vec::new();
|
|
for (i, line) in content.split('\n').enumerate() {
|
|
let line_num = (i + 1) as f64;
|
|
if line_looks_commented(line) {
|
|
continue;
|
|
}
|
|
if ds.has_fonts {
|
|
for m in FONT_DECL_RE.captures_iter(line) {
|
|
findings.extend(check_font_stack(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"font-family",
|
|
));
|
|
}
|
|
for m in FONT_JS_RE.captures_iter(line) {
|
|
findings.extend(check_font_stack(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"fontFamily",
|
|
));
|
|
}
|
|
for m in GOOGLE_FONT_RE.find_iter(line) {
|
|
let url = m.as_str();
|
|
for fm in GOOGLE_FAMILY_PARAM_RE.captures_iter(url) {
|
|
let font = normalize_font_name(&decode_google_family(&fm[1]));
|
|
if font.is_empty() || is_allowed_font(&font, Some(ds)) {
|
|
continue;
|
|
}
|
|
let display = decode_google_family(&fm[1]);
|
|
findings.push(make_design_finding(
|
|
"design-system-font",
|
|
file_path,
|
|
&format!("Google Fonts: {display} is not declared in DESIGN.md typography"),
|
|
line_num,
|
|
&display,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
if ds.has_colors {
|
|
for m in CSS_COLOR_RE.find_iter(line) {
|
|
if !is_probably_color_literal(line, m.start(), m.as_str()) {
|
|
continue;
|
|
}
|
|
let raw = css_color_label(m.as_str());
|
|
if is_allowed_color_raw(&raw, Some(ds)) {
|
|
continue;
|
|
}
|
|
if is_shadow_property_context(line, m.start())
|
|
&& is_allowed_shadow_color_raw(&raw, Some(ds))
|
|
{
|
|
continue;
|
|
}
|
|
findings.push(make_design_finding(
|
|
"design-system-color",
|
|
file_path,
|
|
&format!("Undocumented color {raw} is outside DESIGN.md colors"),
|
|
line_num,
|
|
&raw,
|
|
));
|
|
}
|
|
}
|
|
if ds.has_radii {
|
|
for m in BORDER_RADIUS_RE.captures_iter(line) {
|
|
findings.extend(check_radius_value(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"border-radius",
|
|
));
|
|
}
|
|
for m in BORDER_RADIUS_JS_RE.captures_iter(line) {
|
|
findings.extend(check_radius_value(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"borderRadius",
|
|
));
|
|
}
|
|
}
|
|
if ds.has_font_sizes {
|
|
for m in FONT_SIZE_DECL_RE.captures_iter(line) {
|
|
findings.extend(check_font_size_value(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"font-size",
|
|
));
|
|
}
|
|
for m in FONT_SIZE_JS_RE.captures_iter(line) {
|
|
findings.extend(check_font_size_value(
|
|
&m[1], file_path, line_num, ds, "fontSize",
|
|
));
|
|
}
|
|
for m in TAILWIND_FONT_SIZE_RE.captures_iter(line) {
|
|
findings.extend(check_font_size_value(
|
|
&m[1],
|
|
file_path,
|
|
line_num,
|
|
ds,
|
|
"text-[…] class",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
dedupe_design_findings(findings)
|
|
}
|
|
|
|
/// JS: design-system.mjs#isTransparentCss
|
|
pub fn is_transparent_css(value: &str) -> bool {
|
|
let text = js::to_lower_case(js::trim(value));
|
|
if text.is_empty() || text == "transparent" {
|
|
return true;
|
|
}
|
|
match parse_design_color(&text) {
|
|
Some(p) => p.alpha_or_one() <= 0.05,
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn finding_ignore_or_value(item: &Finding) -> String {
|
|
match item.extras.get("ignoreValue") {
|
|
Some(Value::String(s)) if !s.is_empty() => return s.clone(),
|
|
_ => {}
|
|
}
|
|
match item.extras.get("value") {
|
|
Some(Value::String(s)) if !s.is_empty() => s.clone(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
re!(GOOGLE_FONTS_LABEL_RE, ci("google fonts"));
|
|
|
|
fn canonical_design_finding_key(item: &Finding) -> Option<String> {
|
|
let ap = item.antipattern.as_str();
|
|
if !ap.starts_with("design-system-") {
|
|
return None;
|
|
}
|
|
let value = finding_ignore_or_value(item);
|
|
match ap {
|
|
"design-system-font" => {
|
|
let context = if GOOGLE_FONTS_LABEL_RE.is_match(&item.snippet) {
|
|
"google-font"
|
|
} else {
|
|
"font"
|
|
};
|
|
let font = normalize_font_name(&value);
|
|
if font.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("{ap}:{context}:{font}"))
|
|
}
|
|
}
|
|
"design-system-color" => {
|
|
if let Some(parsed) = parse_design_color(&value) {
|
|
return Some(format!("{ap}:color:{}", color_key(&parsed)));
|
|
}
|
|
let label = js::to_lower_case(&css_color_label(&value));
|
|
if label.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("{ap}:color:{label}"))
|
|
}
|
|
}
|
|
"design-system-radius" | "design-system-font-size" => {
|
|
let kind = if ap == "design-system-radius" {
|
|
"radius"
|
|
} else {
|
|
"font-size"
|
|
};
|
|
let trimmed = js::trim(&value);
|
|
if let Some(px) = resolve_length_px(Some(trimmed), 16.0) {
|
|
if px.is_finite() {
|
|
return Some(format!(
|
|
"{ap}:{kind}:{}",
|
|
number_to_string(math_round(px * 100.0) / 100.0)
|
|
));
|
|
}
|
|
}
|
|
let label = js::to_lower_case(trimmed);
|
|
if label.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!("{ap}:{kind}:{label}"))
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// JS: design-system.mjs#mergeDesignSystemFindings
|
|
pub fn merge_design_system_findings(groups: Vec<Vec<Finding>>) -> Vec<Finding> {
|
|
let mut out: Vec<Finding> = Vec::new();
|
|
let mut seen: Vec<(String, usize)> = Vec::new();
|
|
for group in groups {
|
|
for item in group {
|
|
if let Some(key) = canonical_design_finding_key(&item) {
|
|
if let Some((_, idx)) = seen.iter().find(|(k, _)| *k == key) {
|
|
let existing = &mut out[*idx];
|
|
if existing.line <= 0.0 && item.line > 0.0 {
|
|
existing.line = item.line;
|
|
}
|
|
continue;
|
|
}
|
|
seen.push((key, out.len()));
|
|
}
|
|
out.push(item);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn dedupe_design_findings(findings: Vec<Finding>) -> Vec<Finding> {
|
|
let mut out: Vec<Finding> = Vec::new();
|
|
let mut seen: Vec<String> = Vec::new();
|
|
for item in findings {
|
|
let value = {
|
|
let iv = finding_ignore_or_value_only(&item);
|
|
if iv.is_empty() {
|
|
item.snippet.clone()
|
|
} else {
|
|
iv
|
|
}
|
|
};
|
|
let key = format!(
|
|
"{}\0{}\0{}",
|
|
item.antipattern,
|
|
number_to_string(if item.line != 0.0 { item.line } else { 0.0 }),
|
|
normalize_font_name(&value)
|
|
);
|
|
if seen.contains(&key) {
|
|
continue;
|
|
}
|
|
seen.push(key);
|
|
out.push(item);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn finding_ignore_or_value_only(item: &Finding) -> String {
|
|
match item.extras.get("ignoreValue") {
|
|
Some(Value::String(s)) if !s.is_empty() => s.clone(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// REN-406: the halfday design document's one rule about caps.
|
|
#[test]
|
|
fn declared_component_selectors_reads_the_document() {
|
|
let md = "# Halfday design system\n\n- Plain British English, sentence case everywhere.\n No Title Case, no ALL CAPS outside the `.eyebrow` class.\n- `styles/tokens.css` is the only file with a raw colour, `--ink-900` or `#0f172a`.\n- **Button.** Four kinds and no more: `.btn-primary`, `.btn-secondary`,\n `.btn-ghost`, `.btn-danger`. And `.btn-primary` again.\n";
|
|
assert_eq!(
|
|
declared_component_selectors(md),
|
|
vec![
|
|
".eyebrow",
|
|
".btn-primary",
|
|
".btn-secondary",
|
|
".btn-ghost",
|
|
".btn-danger"
|
|
]
|
|
);
|
|
assert!(declared_component_selectors("nothing to declare").is_empty());
|
|
}
|
|
|
|
/// A document also writes down what it does not want, and exempting those
|
|
/// classes silences exactly the misuse the document forbids.
|
|
#[test]
|
|
fn a_negative_example_declares_nothing() {
|
|
// Condemned in its own clause, by several spellings.
|
|
let md = "- Avoid `.eyebrow`; it shouts.\n- `.card-old` is deprecated.\n - Use `.kicker` instead of `.eyebrow-legacy`.\n - Every section label is a `.kicker`, not a `.tagline`.\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// A section of counter-examples, and its subsections with it.
|
|
let md = "## Components\n\n- The label above a heading is `.kicker`.\n\n ## Don't\n\n### Labels\n\n- `.eyebrow` anywhere.\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// Condemned once is condemned: a class the document calls deprecated
|
|
// is not rescued by a list that also names it.
|
|
let md = "- Buttons: `.btn-primary`, `.btn-old`.\n- `.btn-old` is deprecated.\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".btn-primary"]);
|
|
|
|
// A directive governs what follows it, not the whole clause: a
|
|
// sentence that sanctions one class and forbids another says both.
|
|
let md = "- Use `.kicker` and never `.tagline`.\n\
|
|
- Every label is a `.kicker`, not a `.tagline`.\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// Headings that retire a set, in the words a document uses for it.
|
|
let md = "## Retired components\n\n- `.tagline`\n\n\
|
|
## Unsupported patterns\n\n- `.marquee-row`\n\n\
|
|
## Components\n\n- `.kicker`\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// A heading that names both sides heads a section of both, and its
|
|
// subsections are what say which is which.
|
|
let md = "## Dos and Don'ts\n\n### Do\n\n- Label a section with `.kicker`.\n\n\
|
|
### Don't\n\n- Reach for `.eyebrow`.\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// Compound separators pair the two sides just as well.
|
|
for heading in ["Do's, and Don'ts", "Do and/or Don't", "Don'ts / Dos"] {
|
|
let md = format!("## {heading}\n\n### Do\n\n- Use `.kicker`.\n");
|
|
assert_eq!(declared_component_selectors(&md), vec![".kicker"], "{heading}");
|
|
}
|
|
|
|
// A heading that only puts a `do` beside a `don't` is not a section
|
|
// of both, and still condemns what it names.
|
|
let md = "## Don't do this\n\n- `.eyebrow` above a heading.\n\n\
|
|
## What we don't do\n\n- `.tagline`\n\n\
|
|
## Components\n\n- `.kicker`\n";
|
|
assert_eq!(declared_component_selectors(md), vec![".kicker"]);
|
|
|
|
// What the negative words govern is their own clause. "No ALL CAPS
|
|
// outside the `.eyebrow` class" declares `.eyebrow`, and "four kinds
|
|
// and no more" declares all four.
|
|
let md = "- No Title Case, no ALL CAPS outside the `.eyebrow` class.\n - **Button.** Four kinds and no more: `.btn-primary` (one per\n surface), `.btn-secondary`, `.btn-ghost`, `.btn-danger`.\n";
|
|
assert_eq!(
|
|
declared_component_selectors(md),
|
|
vec![".eyebrow", ".btn-primary", ".btn-secondary", ".btn-ghost", ".btn-danger"]
|
|
);
|
|
}
|
|
|
|
// ── #570 monorepo DESIGN.md inheritance ─────────────────────────────────
|
|
// Mirrors tests/detect-cli-design-monorepo.test.mjs (public repo main,
|
|
// 47e41195 + 5d7c1cce + e975bec4 + 91f2c7b4) at the findDesignRoot level.
|
|
|
|
struct TempDir(std::path::PathBuf);
|
|
impl TempDir {
|
|
fn new(prefix: &str) -> TempDir {
|
|
let d = std::env::temp_dir().join(format!(
|
|
"impeccable-ds-{}-{}-{:?}",
|
|
prefix,
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
TempDir(std::fs::canonicalize(&d).unwrap())
|
|
}
|
|
fn path(&self) -> String {
|
|
self.0.to_string_lossy().into_owned()
|
|
}
|
|
fn write(&self, rel: &str, content: &str) {
|
|
let p = self.0.join(rel);
|
|
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
std::fs::write(p, content).unwrap();
|
|
}
|
|
fn mkdir(&self, rel: &str) {
|
|
std::fs::create_dir_all(self.0.join(rel)).unwrap();
|
|
}
|
|
fn join(&self, rel: &str) -> String {
|
|
self.0.join(rel).to_string_lossy().into_owned()
|
|
}
|
|
}
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
const DESIGN_MD: &str = "---\ntypography:\n body:\n fontFamily: \"Palatino, Georgia, serif\"\n---\n# Project A Design System\n";
|
|
|
|
fn root_of(dir: &TempDir, rel: &str, home: &str) -> Option<DesignRoot> {
|
|
find_design_root(&dir.join(rel), "/", home)
|
|
}
|
|
|
|
/// An isolated home no walk in these fixtures ever reaches.
|
|
fn far_home() -> String {
|
|
"/nonexistent-home-for-design-system-tests".to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn monorepo_pnpm_workspace_inherits_root_design() {
|
|
let d = TempDir::new("pnpm");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
d.write("apps/web/package.json", "{\"name\":\"web\"}");
|
|
let found = root_of(&d, "apps/web", &far_home()).unwrap();
|
|
assert_eq!(found.dir, d.path());
|
|
assert!(found.has_design);
|
|
}
|
|
|
|
#[test]
|
|
fn monorepo_npm_workspaces_and_yarn_object_form() {
|
|
let d = TempDir::new("npm");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("package.json", "{\"name\":\"mono\",\"workspaces\":[\"packages/*\"]}");
|
|
d.write("packages/ui/package.json", "{\"name\":\"ui\"}");
|
|
let found = root_of(&d, "packages/ui", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true));
|
|
|
|
let y = TempDir::new("yarnobj");
|
|
y.write("DESIGN.md", DESIGN_MD);
|
|
y.write("package.json", "{\"name\":\"mono\",\"workspaces\":{\"packages\":[\"packages/*\"]}}");
|
|
y.write("packages/ui/package.json", "{\"name\":\"ui\"}");
|
|
let found = root_of(&y, "packages/ui", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (y.path(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn monorepo_marker_roots_own_apps_and_packages() {
|
|
for marker in ["turbo.json", "nx.json"] {
|
|
let d = TempDir::new(&marker.replace('.', "-"));
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("package.json", "{\"name\":\"mono\"}");
|
|
d.write(marker, "{}");
|
|
d.write("apps/web/package.json", "{\"name\":\"web\"}");
|
|
let found = root_of(&d, "apps/web", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true), "{}", marker);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn monorepo_lerna_and_impeccable_project_roots() {
|
|
let d = TempDir::new("lerna");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("lerna.json", "{\"packages\":[\"modules/*\"]}");
|
|
d.write("modules/web/package.json", "{\"name\":\"web\"}");
|
|
let found = root_of(&d, "modules/web", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true));
|
|
|
|
let i = TempDir::new("iroots");
|
|
i.write("DESIGN.md", DESIGN_MD);
|
|
i.write(".impeccable/config.json", "{\"projectRoots\":[\"sites/*\"]}");
|
|
i.write("sites/docs/package.json", "{\"name\":\"docs\"}");
|
|
let found = root_of(&i, "sites/docs", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (i.path(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn pnpm_flow_list_with_inline_comment() {
|
|
// 91f2c7b4: an inline YAML comment must not defeat glob recognition.
|
|
let d = TempDir::new("flow");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("pnpm-workspace.yaml", "packages: [\"services/*\"] # deploy targets\n");
|
|
d.write("services/api/package.json", "{\"name\":\"api\"}");
|
|
let found = root_of(&d, "services/api", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_owned_design_md_wins() {
|
|
let d = TempDir::new("wsdesign");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
d.write("apps/web/package.json", "{\"name\":\"web\"}");
|
|
d.write("apps/web/DESIGN.md", DESIGN_MD);
|
|
let found = root_of(&d, "apps/web", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.join("apps/web"), true));
|
|
}
|
|
|
|
#[test]
|
|
fn negated_workspace_package_does_not_inherit() {
|
|
let d = TempDir::new("negated");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write(
|
|
"package.json",
|
|
"{\"name\":\"mono\",\"workspaces\":[\"packages/*\",\"!packages/excluded\"]}",
|
|
);
|
|
d.write("packages/included/package.json", "{\"name\":\"included\"}");
|
|
d.write("packages/excluded/package.json", "{\"name\":\"excluded\"}");
|
|
let inc = root_of(&d, "packages/included", &far_home()).unwrap();
|
|
assert_eq!((inc.dir, inc.has_design), (d.path(), true));
|
|
let exc = root_of(&d, "packages/excluded", &far_home()).unwrap();
|
|
assert_eq!((exc.dir.clone(), exc.has_design), (d.join("packages/excluded"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn stray_nested_package_outside_globs_does_not_inherit() {
|
|
let d = TempDir::new("stray");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
d.write("apps/web/package.json", "{\"name\":\"web\"}");
|
|
d.write("vendor/tool/package.json", "{\"name\":\"tool\"}");
|
|
let web = root_of(&d, "apps/web", &far_home()).unwrap();
|
|
assert!(web.has_design);
|
|
let vendor = root_of(&d, "vendor/tool", &far_home()).unwrap();
|
|
assert_eq!((vendor.dir.clone(), vendor.has_design), (d.join("vendor/tool"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_separate_repo_inherits_nothing() {
|
|
let d = TempDir::new("nestedrepo");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
d.mkdir("vendor/other/.git");
|
|
d.write("vendor/other/package.json", "{\"name\":\"other\"}");
|
|
let found = root_of(&d, "vendor/other", &far_home()).unwrap();
|
|
assert_eq!((found.dir.clone(), found.has_design), (d.join("vendor/other"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn non_monorepo_nested_package_does_not_inherit() {
|
|
let d = TempDir::new("nestedpkg");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("package.json", "{\"name\":\"root\"}");
|
|
d.mkdir(".git");
|
|
d.write("packages/nested/package.json", "{\"name\":\"nested\"}");
|
|
let found = root_of(&d, "packages/nested", &far_home()).unwrap();
|
|
assert_eq!((found.dir.clone(), found.has_design), (d.join("packages/nested"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn single_package_repo_and_docs_fallback_still_inherit() {
|
|
let d = TempDir::new("single");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("package.json", "{\"name\":\"app\"}");
|
|
d.mkdir("src");
|
|
let found = root_of(&d, "src", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true));
|
|
|
|
let f = TempDir::new("docsfb");
|
|
f.write("docs/DESIGN.md", DESIGN_MD);
|
|
f.write("package.json", "{\"name\":\"app\"}");
|
|
f.mkdir("src");
|
|
let found = root_of(&f, "src", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (f.path(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn globstar_negation_spares_sibling_workspaces() {
|
|
let d = TempDir::new("globstar");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write(
|
|
"pnpm-workspace.yaml",
|
|
"packages:\n - 'packages/*'\n - 'components/**'\n - '!**/test/**'\n",
|
|
);
|
|
d.write("packages/ui/package.json", "{\"name\":\"ui\"}");
|
|
d.write("components/button/package.json", "{\"name\":\"button\"}");
|
|
d.write("packages/ui/test/fixture/package.json", "{\"name\":\"fixture\"}");
|
|
let ui = root_of(&d, "packages/ui", &far_home()).unwrap();
|
|
assert_eq!((ui.dir, ui.has_design), (d.path(), true));
|
|
let button = root_of(&d, "components/button", &far_home()).unwrap();
|
|
assert_eq!((button.dir, button.has_design), (d.path(), true));
|
|
let test = root_of(&d, "packages/ui/test/fixture", &far_home()).unwrap();
|
|
assert_eq!(
|
|
(test.dir.clone(), test.has_design),
|
|
(d.join("packages/ui/test/fixture"), false)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn star_owns_direct_children_and_their_nested_packages_only() {
|
|
let d = TempDir::new("star");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write("package.json", "{\"name\":\"mono\",\"workspaces\":[\"*\"]}");
|
|
d.write("web/package.json", "{\"name\":\"web\"}");
|
|
d.write("web/examples/package.json", "{\"name\":\"examples\"}");
|
|
d.write("vendor/tool/package.json", "{\"name\":\"tool\"}");
|
|
let web = root_of(&d, "web", &far_home()).unwrap();
|
|
assert_eq!((web.dir, web.has_design), (d.path(), true));
|
|
// 47e41195: a nested package under a matched workspace is still owned.
|
|
let nested = root_of(&d, "web/examples", &far_home()).unwrap();
|
|
assert_eq!((nested.dir, nested.has_design), (d.path(), true));
|
|
let vendor = root_of(&d, "vendor/tool", &far_home()).unwrap();
|
|
assert_eq!((vendor.dir.clone(), vendor.has_design), (d.join("vendor/tool"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn impeccable_project_roots_beat_package_manager_negation() {
|
|
// 47e41195: projectRoots govern a path they match even when
|
|
// package-manager workspaces exclude it.
|
|
let d = TempDir::new("irootswin");
|
|
d.write("DESIGN.md", DESIGN_MD);
|
|
d.write(".impeccable/config.json", "{\"projectRoots\":[\"sites/*\"]}");
|
|
d.write(
|
|
"package.json",
|
|
"{\"name\":\"mono\",\"workspaces\":[\"sites/*\",\"!sites/docs\"]}",
|
|
);
|
|
d.write("sites/docs/package.json", "{\"name\":\"docs\"}");
|
|
let found = root_of(&d, "sites/docs", &far_home()).unwrap();
|
|
assert_eq!((found.dir, found.has_design), (d.path(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn home_directory_is_never_an_owning_root() {
|
|
// e975bec4: a workspace-declaring $HOME must not leak its DESIGN.md
|
|
// into every git-less project beneath it.
|
|
let home = TempDir::new("home");
|
|
home.write("DESIGN.md", DESIGN_MD);
|
|
home.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
home.write("project/package.json", "{\"name\":\"p\"}");
|
|
let found = find_design_root(&home.join("project"), "/", &home.path()).unwrap();
|
|
assert_eq!((found.dir.clone(), found.has_design), (home.join("project"), false));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn symlinked_home_still_stops_the_walk() {
|
|
let real = TempDir::new("realhome");
|
|
real.write("DESIGN.md", DESIGN_MD);
|
|
real.write("pnpm-workspace.yaml", "packages:\n - 'apps/*'\n");
|
|
real.write("project/package.json", "{\"name\":\"p\"}");
|
|
let link = std::env::temp_dir().join(format!("impeccable-ds-linkhome-{}", std::process::id()));
|
|
let _ = std::fs::remove_file(&link);
|
|
std::os::unix::fs::symlink(&real.0, &link).unwrap();
|
|
// HOME is the symlink; the target is the physical path, so a
|
|
// logical-only comparison would walk straight past home and inherit.
|
|
let found = find_design_root(&real.join("project"), "/", &link.to_string_lossy()).unwrap();
|
|
let _ = std::fs::remove_file(&link);
|
|
assert_eq!((found.dir.clone(), found.has_design), (real.join("project"), false));
|
|
}
|
|
|
|
#[test]
|
|
fn frontmatter_and_fonts() {
|
|
let md = "---\ntypography:\n body:\n fontFamily: \"Palatino, Georgia, serif\"\ncolors:\n primary: \"#1a4d8f\"\n---\nbody";
|
|
let fm = parse_frontmatter(md).unwrap();
|
|
let ds = normalize_design_system(Some(&fm), None, None, None, false);
|
|
assert_eq!(
|
|
ds.allowed_fonts,
|
|
vec!["palatino".to_string(), "georgia".to_string()]
|
|
);
|
|
assert!(ds.has_colors);
|
|
assert!(!is_allowed_font("inter", Some(&ds)));
|
|
let f = check_source_design_system(
|
|
"a { color: #ff0000; font-family: Inter }",
|
|
"x.css",
|
|
Some(&ds),
|
|
);
|
|
assert_eq!(f.len(), 2);
|
|
assert_eq!(
|
|
f[0].snippet,
|
|
"font-family: Inter is not declared in DESIGN.md typography"
|
|
);
|
|
}
|
|
|
|
// upstream 1bcdf80f / #687: var() radius fallbacks leave the closing
|
|
// paren on the final token; strip it so `8px)` is not read as unitless.
|
|
#[test]
|
|
fn var_radius_fallback_strips_closing_paren() {
|
|
let md = "---\nrounded:\n md: \"8px\"\n---\nbody";
|
|
let fm = parse_frontmatter(md).unwrap();
|
|
let ds = normalize_design_system(Some(&fm), None, None, None, false);
|
|
let css = ".good {\n border-radius: var(--radius-md, 8px);\n}\n.bad {\n border-radius: var(--radius-custom, 18px);\n}\n";
|
|
let f = check_source_design_system(css, "/tmp/radius-fallbacks.css", Some(&ds));
|
|
let got: Vec<(String, Option<String>)> = f
|
|
.iter()
|
|
.map(|item| {
|
|
(
|
|
item.antipattern.clone(),
|
|
item.extras.get("ignoreValue").and_then(|v| v.as_str().map(String::from)),
|
|
)
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![("design-system-radius".to_string(), Some("18px".to_string()))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn yaml_scalars() {
|
|
assert_eq!(parse_scalar("\"a\\\"b\""), Value::String("a\"b".into()));
|
|
assert_eq!(parse_scalar("'it''s'"), Value::String("it's".into()));
|
|
assert_eq!(js_string(&parse_scalar("007")), "7");
|
|
}
|
|
}
|
|
|
|
|