///|
pub struct AssetPolicy {
allowed_extensions : Array[String]
min_width_px : Int
max_path_length : Int
reject_parent_segments : Bool
require_primary_asset : Bool
} derive(Debug, Eq)
///|
pub fn AssetPolicy::default() -> AssetPolicy {
{
allowed_extensions: ["svg", "png", "jpg", "jpeg", "webp"],
min_width_px: 16,
max_path_length: 240,
reject_parent_segments: true,
require_primary_asset: true,
}
}
///|
pub fn AssetPolicy::strict() -> AssetPolicy {
{
allowed_extensions: ["svg", "png", "jpg", "jpeg", "webp"],
min_width_px: 32,
max_path_length: 160,
reject_parent_segments: true,
require_primary_asset: true,
}
}
///|
pub fn AssetPolicy::with_min_width(
self : AssetPolicy,
width : Int,
) -> AssetPolicy {
{ ..self, min_width_px: if width < 0 { 0 } else { width } }
}
///|
pub fn Space::pixels(value : Int) -> Space {
Pixels(value)
}
///|
pub fn Space::multiplier(value : Double) -> Space {
Multiplier(value)
}
///|
pub fn BackgroundRule::light_or_dark() -> BackgroundRule {
LightOrDark
}
///|
pub fn Logo::new(
name : String,
path : String,
min_width_px : Int,
clear_space : Space,
background : BackgroundRule,
) -> Logo {
{ name, path, min_width_px, clear_space, background }
}
///|
pub fn Logo::extension(self : Logo) -> String {
asset_extension(self.path)
}
///|
pub fn Logo::is_vector(self : Logo) -> Bool {
self.extension() == "svg"
}
///|
pub fn Logo::is_raster(self : Logo) -> Bool {
self.extension() == "png" ||
self.extension() == "jpg" ||
self.extension() == "jpeg" ||
self.extension() == "webp"
}
///|
pub fn Logo::audit(self : Logo, policy : AssetPolicy) -> AuditReport {
let findings : Array[Finding] = []
if self.name.trim().to_owned() == "" {
findings.push(Finding::{
severity: Error,
path: "name",
message: "asset name is required",
})
}
if self.path.trim().to_owned() == "" {
findings.push(Finding::{
severity: Error,
path: "path",
message: "asset path is required",
})
}
if self.path.length() > policy.max_path_length {
findings.push(Finding::{
severity: Error,
path: "path",
message: "asset path is too long",
})
}
if policy.reject_parent_segments &&
(self.path.contains("../") || self.path.contains("..\\")) {
findings.push(Finding::{
severity: Error,
path: "path",
message: "asset path cannot escape the project root",
})
}
if !asset_has_supported_extension(self.path, policy.allowed_extensions) {
findings.push(Finding::{
severity: Error,
path: "path",
message: "asset extension is not supported",
})
}
if self.min_width_px < policy.min_width_px {
findings.push(Finding::{
severity: Error,
path: "min_width_px",
message: "asset is below the minimum display width",
})
}
match self.clear_space {
Pixels(value) if value <= 0 =>
findings.push(Finding::{
severity: Error,
path: "clear_space",
message: "clear space must be positive",
})
Multiplier(value) if value <= 0.0 =>
findings.push(Finding::{
severity: Error,
path: "clear_space",
message: "clear-space multiplier must be positive",
})
_ => ()
}
AuditReport::from_findings(findings)
}
///|
pub fn AssetSet::audit(self : AssetSet, policy : AssetPolicy) -> AuditReport {
let findings : Array[Finding] = []
if policy.require_primary_asset && self.primary_logo.path == "" {
findings.push(Finding::{
severity: Error,
path: "primary_logo",
message: "primary logo is required",
})
}
let primary = self.primary_logo.audit(policy)
for finding in primary.findings {
findings.push(Finding::{ ..finding, path: "primary_logo." + finding.path })
}
for i, logo in self.alternate_logos {
let report = logo.audit(policy)
for finding in report.findings {
findings.push(Finding::{
..finding,
path: "alternate_logos[" + i.to_string() + "]." + finding.path,
})
}
}
AuditReport::from_findings(findings)
}
///|
pub fn BrandBook::asset_health(self : BrandBook) -> Int {
let report = self.assets.audit(AssetPolicy::default())
let total = 1 + self.assets.alternate_logos.length()
if report.has_errors() {
0
} else {
total * 100 / total
}
}
///|
pub fn AssetSet::all_paths(self : AssetSet) -> Array[String] {
let paths : Array[String] = [self.primary_logo.path]
for logo in self.alternate_logos {
paths.push(logo.path)
}
paths
}
///|
pub fn normalize_asset_path(path : String) -> String {
let value = path.replace(old="\\", new="/")
let parts = value.split("/")
let result : Array[String] = []
for part in parts {
let trimmed = part.trim().to_owned()
if trimmed != "" && trimmed != "." {
result.push(trimmed)
}
}
result.join("/")
}
///|
pub fn is_safe_asset_path(path : String) -> Bool {
let normalized = normalize_asset_path(path)
!normalized.has_prefix("../") &&
normalized != ".." &&
!normalized.contains("/../")
}
///|
pub fn asset_extension(path : String) -> String {
let mut extension = StringBuilder::new()
for ch in path {
if ch == '.' {
extension = StringBuilder::new()
} else {
extension.write_char(ch)
}
}
ascii_lower(extension.to_string())
}
///|
pub fn asset_has_supported_extension(
path : String,
extensions : Array[String],
) -> Bool {
let extension = asset_extension(path)
extensions.any(fn(item) { ascii_lower(item) == extension })
}