badge_maker_rs/lib.rs
1//! Rust badge renderer targeting Shields-compatible badge output.
2//!
3//! Start with [`BadgeOptions::new`] and render with [`make_badge`].
4//!
5//! ```
6//! use badge_maker_rs::{BadgeOptions, Color, Style, make_badge};
7//!
8//! let svg = make_badge(
9//! &BadgeOptions::new("passing")
10//! .label("build")
11//! .color("brightgreen".parse::<Color>().unwrap())
12//! .style(Style::Flat)
13//! .build(),
14//! )
15//! .unwrap();
16//!
17//! assert!(svg.starts_with("<svg "));
18//! assert!(svg.contains("aria-label=\"build: passing\""));
19//! ```
20//!
21//! The public API is intentionally Rust-first. The compatibility target is the
22//! final rendered badge image: pixel-equivalent output matters more than SVG
23//! source parity, and JavaScript-specific API shape is out of scope.
24
25use bon::Builder;
26mod anafanafo;
27mod badge_color;
28mod render;
29
30pub use badge_color::{Color, NamedColor, ParseColorError};
31
32use thiserror::Error;
33
34/// Badge visual style.
35#[doc = include_str!(concat!(env!("OUT_DIR"), "/style-previews.md"))]
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum Style {
38 Plastic,
39 Flat,
40 FlatSquare,
41 ForTheBadge,
42 Social,
43}
44
45/// Badge rendering options.
46///
47/// Construct values with [`BadgeOptions::new`]. `message` is required at the
48/// start function; the remaining fields are optional overrides.
49///
50/// `badge-maker` naming notes:
51///
52/// - `left_link` / `right_link` correspond to the two link slots encoded in the original `badge-maker` `links`
53/// - `logo_data_url` corresponds to the original `badge-maker` field name `logoBase64`
54/// - `logo_width` corresponds to the original `badge-maker` field name `logoWidth`
55/// - `id_suffix` corresponds to the original `badge-maker` field name `idSuffix`
56///
57/// Link configuration is modeled as two explicit slots:
58///
59/// - `left_link` only: wraps the full badge body
60/// - `right_link` only: links only the right half
61/// - both present: links left and right halves independently
62#[derive(Clone, Debug, Eq, PartialEq, Builder)]
63#[builder(start_fn = new)]
64pub struct BadgeOptions {
65 /// Right-hand message text.
66 #[builder(start_fn, into)]
67 pub message: String,
68 /// Left-hand label text. Empty by default.
69 #[builder(default, into)]
70 pub label: String,
71 /// Badge visual style.
72 #[builder(default = Style::Flat)]
73 pub style: Style,
74 /// Message-side color override.
75 #[builder(into)]
76 pub color: Option<Color>,
77 /// Label-side color override.
78 #[builder(into)]
79 pub label_color: Option<Color>,
80 /// Optional link for the left slot.
81 ///
82 /// This is the Rust-side left-slot projection of `badge-maker`'s `links`.
83 ///
84 /// When this is the only link provided, the full badge body is wrapped by
85 /// one anchor to match `badge-maker` compatibility behavior.
86 #[builder(into)]
87 pub left_link: Option<String>,
88 /// Optional link for the right slot.
89 ///
90 /// This is the Rust-side right-slot projection of `badge-maker`'s `links`.
91 ///
92 /// When only `right_link` is set, only the right half is linked.
93 #[builder(into)]
94 pub right_link: Option<String>,
95 /// Optional logo data URL embedded as an SVG `<image>`.
96 ///
97 /// This corresponds to the original `badge-maker` field name `logoBase64`, but
98 /// uses a Rust-side name that describes the actual expected value shape.
99 #[builder(into)]
100 pub logo_data_url: Option<String>,
101 /// Explicit logo width in pixels.
102 ///
103 /// This corresponds to the original `badge-maker` field name `logoWidth`.
104 ///
105 /// It remains a Rust-side convenience override: only set it when the
106 /// desired rendered logo width is already known.
107 pub logo_width: Option<u32>,
108 /// Optional suffix appended to generated SVG IDs to avoid collisions on the same page.
109 ///
110 /// This corresponds to the original `badge-maker` field name `idSuffix`.
111 #[builder(into)]
112 pub id_suffix: Option<String>,
113}
114
115/// Rendering errors returned by [`make_badge`].
116#[derive(Debug, Error, Clone, Eq, PartialEq)]
117pub enum Error {
118 /// `id_suffix` contained characters outside `[A-Za-z0-9_-]`.
119 #[error("Field `idSuffix` must contain only numbers, letters, -, and _")]
120 InvalidIdSuffix,
121 /// Width measurement failed in the internal `anafanafo` compatibility layer.
122 #[error("anafanafo measurement failed: {0}")]
123 WidthComputation(String),
124}
125
126/// Renders a complete SVG badge document from [`BadgeOptions`].
127///
128/// Invalid `id_suffix` returns [`Error::InvalidIdSuffix`]. Invalid
129/// [`Color::Literal`] values fall back to defaults instead of erroring.
130///
131/// The returned SVG is an intermediate representation. The primary
132/// compatibility goal of this crate is the final rendered image, with
133/// pixel-level parity against `badge-maker` used as the main target.
134pub fn make_badge(options: &BadgeOptions) -> Result<String, Error> {
135 let id_suffix = options.id_suffix.clone().unwrap_or_default();
136 if !id_suffix
137 .chars()
138 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
139 {
140 return Err(Error::InvalidIdSuffix);
141 }
142
143 let label = options.label.trim().to_owned();
144 let message = options.message.trim().to_owned();
145 render::render_svg(options, label, message, id_suffix)
146}