Skip to main content

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}