vize_musea 0.178.0

Musea - Component gallery and documentation for Vize Vue components
Documentation
use std::{fs, path::Path};

use serde_json::{Map, Value};
use vize_carton::{FxHashMap, String, ToCompactString};

use super::types::{DesignToken, TokenCategory, TokenError, TokenResult};

#[allow(clippy::disallowed_types)]
type JsonObject = Map<std::string::String, Value>;

pub fn parse_tokens_from_path(path: impl AsRef<Path>) -> TokenResult<Vec<TokenCategory>> {
    let path = path.as_ref();
    let metadata = fs::metadata(path).map_err(io_error)?;
    if metadata.is_dir() {
        let mut root = Value::Object(Map::new());
        merge_token_directory(&mut root, path)?;
        return Ok(parse_tokens_from_value(&root));
    }

    let bytes = fs::read(path).map_err(io_error)?;
    let value = serde_json::from_slice::<Value>(&bytes).map_err(json_error)?;
    Ok(parse_tokens_from_value(&value))
}

pub fn parse_tokens_from_json(source: &str) -> TokenResult<Vec<TokenCategory>> {
    let value = serde_json::from_str::<Value>(source).map_err(json_error)?;
    Ok(parse_tokens_from_value(&value))
}

pub fn parse_tokens_from_value(value: &Value) -> Vec<TokenCategory> {
    let Some(object) = value.as_object() else {
        return Vec::new();
    };
    flatten_token_tree(object)
}

fn merge_token_directory(target: &mut Value, dir: &Path) -> TokenResult<()> {
    let mut entries = fs::read_dir(dir)
        .map_err(io_error)?
        .collect::<Result<Vec<_>, _>>()
        .map_err(io_error)?;
    entries.sort_by_key(|entry| entry.file_name());

    for entry in entries {
        let path = entry.path();
        let metadata = entry.metadata().map_err(io_error)?;
        if metadata.is_dir() {
            merge_token_directory(target, &path)?;
            continue;
        }
        if !metadata.is_file() || !is_token_file(&path) {
            continue;
        }

        let bytes = fs::read(&path).map_err(io_error)?;
        let value = serde_json::from_slice::<Value>(&bytes).map_err(json_error)?;
        deep_merge_token_trees(target, value);
    }

    Ok(())
}

fn deep_merge_token_trees(target: &mut Value, source: Value) {
    if is_token_leaf(target) || is_token_leaf(&source) {
        *target = source;
        return;
    }

    let Value::Object(source_obj) = source else {
        *target = source;
        return;
    };
    let Some(target_obj) = target.as_object_mut() else {
        *target = Value::Object(source_obj);
        return;
    };

    for (key, value) in source_obj {
        if let Some(existing) = target_obj.get_mut(&key) {
            deep_merge_token_trees(existing, value);
        } else {
            target_obj.insert(key, value);
        }
    }
}

fn flatten_token_tree(object: &JsonObject) -> Vec<TokenCategory> {
    let mut categories = Vec::new();
    for (key, value) in object {
        if is_token_leaf(value) {
            continue;
        }
        let Some(child) = value.as_object() else {
            continue;
        };

        let tokens = extract_tokens(child);
        let subcategories = flatten_token_tree(child);
        if !tokens.is_empty() || !subcategories.is_empty() {
            categories.push(TokenCategory {
                name: format_category_name(key),
                tokens,
                subcategories,
            });
        }
    }
    categories
}

fn extract_tokens(object: &JsonObject) -> FxHashMap<String, DesignToken> {
    let mut tokens = FxHashMap::default();
    for (key, value) in object {
        if let Some(token) = normalize_token(value) {
            tokens.insert(key.as_str().into(), token);
        }
    }
    tokens
}

fn normalize_token(value: &Value) -> Option<DesignToken> {
    let object = value.as_object()?;
    let raw_value = object.get("value").or_else(|| object.get("$value"))?;
    if !raw_value.is_string() && !raw_value.is_number() {
        return None;
    }

    let tier = object
        .get("$tier")
        .and_then(Value::as_str)
        .filter(|tier| *tier == "primitive" || *tier == "semantic")
        .map(Into::into);

    Some(DesignToken {
        value: raw_value.clone(),
        token_type: object
            .get("type")
            .or_else(|| object.get("$type"))
            .and_then(Value::as_str)
            .map(Into::into),
        description: object
            .get("description")
            .and_then(Value::as_str)
            .map(Into::into),
        attributes: object.get("attributes").cloned(),
        tier,
        reference: object
            .get("$reference")
            .and_then(Value::as_str)
            .map(Into::into),
        resolved_value: None,
    })
}

pub(super) fn is_token_leaf(value: &Value) -> bool {
    value.as_object().is_some_and(|object| {
        object
            .get("value")
            .or_else(|| object.get("$value"))
            .is_some_and(|value| value.is_string() || value.is_number())
    })
}

fn is_token_file(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".json") || name.ends_with(".tokens.json"))
}

fn format_category_name(name: &str) -> String {
    let mut result = String::new("");
    let mut word_started = false;
    let mut prev_lowercase = false;

    for ch in name.chars() {
        if ch == '-' || ch == '_' || ch.is_whitespace() {
            word_started = false;
            prev_lowercase = false;
            continue;
        }
        if prev_lowercase && ch.is_ascii_uppercase() {
            word_started = false;
        }
        if !word_started {
            if !result.is_empty() {
                result.push(' ');
            }
            result.push(ch.to_ascii_uppercase());
            word_started = true;
        } else {
            result.push(ch.to_ascii_lowercase());
        }
        prev_lowercase = ch.is_ascii_lowercase();
    }

    if result.is_empty() {
        name.to_compact_string()
    } else {
        result
    }
}

fn io_error(error: std::io::Error) -> TokenError {
    TokenError::Io {
        message: error.to_compact_string(),
    }
}

fn json_error(error: serde_json::Error) -> TokenError {
    TokenError::Json {
        message: error.to_compact_string(),
    }
}