Skip to main content

hypersig_style/
global.rs

1//! [`Global`]: rules for elements by their tag, a reset, and `@font-face`.
2
3use std::fmt::Write as _;
4
5use crate::Property;
6use crate::condition::Conditions;
7use crate::sheet::{BASE_LAYER, ORDER};
8use crate::style::Style;
9use crate::value::{
10    BoxSizing, Color, CssWideKeyword, Display, FontFamily, LineHeight, Quoted, Url, pct, px, quoted,
11};
12
13/// What a [`Global`] rule applies to.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum Selector {
16    /// The document's root, `:root`.
17    Root,
18    /// Every element and its `::before` and `::after`.
19    Everything,
20    /// Every element with the tag.
21    Element(Element),
22}
23
24impl Selector {
25    fn write(self, out: &mut String, suffix: &str) {
26        match self {
27            Self::Root => {
28                let _ = write!(out, ":root{suffix}");
29            }
30            Self::Everything => {
31                let _ = write!(out, "*{suffix},::before{suffix},::after{suffix}");
32            }
33            Self::Element(e) => {
34                let _ = write!(out, "{}{suffix}", e.name());
35            }
36        }
37    }
38}
39
40impl From<Element> for Selector {
41    fn from(e: Element) -> Self {
42        Self::Element(e)
43    }
44}
45
46macro_rules! elements {
47    ($($variant:ident => $tag:literal),* $(,)?) => {
48        /// An HTML element, by its tag.
49        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
50        #[non_exhaustive]
51        pub enum Element {
52            $(
53                #[doc = concat!("`<", $tag, ">`.")]
54                $variant,
55            )*
56        }
57
58        impl Element {
59            /// The tag name.
60            #[must_use]
61            pub fn name(self) -> &'static str {
62                match self {
63                    $(Self::$variant => $tag,)*
64                }
65            }
66        }
67    };
68}
69
70elements! {
71    A => "a", Abbr => "abbr", Address => "address", Article => "article", Aside => "aside",
72    Audio => "audio", B => "b", Blockquote => "blockquote", Body => "body", Button => "button",
73    Canvas => "canvas", Caption => "caption", Code => "code", Dd => "dd", Del => "del",
74    Details => "details", Dialog => "dialog", Div => "div", Dl => "dl", Dt => "dt", Em => "em",
75    Fieldset => "fieldset", Figcaption => "figcaption", Figure => "figure", Footer => "footer",
76    Form => "form", H1 => "h1", H2 => "h2", H3 => "h3", H4 => "h4", H5 => "h5", H6 => "h6",
77    Header => "header", Hr => "hr", Html => "html", I => "i", Iframe => "iframe", Img => "img",
78    Input => "input", Ins => "ins", Kbd => "kbd", Label => "label", Legend => "legend",
79    Li => "li", Main => "main", Mark => "mark", Menu => "menu", Meter => "meter", Nav => "nav",
80    Ol => "ol", Optgroup => "optgroup", Option => "option", Output => "output", P => "p",
81    Picture => "picture", Pre => "pre", Progress => "progress", Q => "q", S => "s",
82    Samp => "samp", Search => "search", Section => "section", Select => "select",
83    Small => "small", Span => "span", Strong => "strong", Sub => "sub", Summary => "summary",
84    Sup => "sup", Svg => "svg", Table => "table", Tbody => "tbody", Td => "td",
85    Textarea => "textarea", Tfoot => "tfoot", Th => "th", Thead => "thead", Time => "time",
86    Tr => "tr", U => "u", Ul => "ul", Video => "video",
87}
88
89/// A font the page loads, written as `@font-face`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct FontFace {
92    family: &'static str,
93    src: Url,
94    weight: Option<(u16, u16)>,
95    italic: bool,
96}
97
98impl FontFace {
99    /// The font `family` loaded from `src`, a WOFF2 file or any format the
100    /// browser recognizes from the file.
101    #[must_use]
102    pub fn new(family: &'static str, src: Url) -> Self {
103        Self {
104            family,
105            src,
106            weight: None,
107            italic: false,
108        }
109    }
110
111    /// The weights the file covers, from `min` to `max`, as a variable font
112    /// declares them. One weight is `weights(400, 400)`.
113    #[must_use]
114    pub fn weights(mut self, min: u16, max: u16) -> Self {
115        self.weight = Some((min, max));
116        self
117    }
118
119    /// Marks the file as the italic face of the family.
120    #[must_use]
121    pub fn italic(mut self) -> Self {
122        self.italic = true;
123        self
124    }
125
126    /// The family as `font-family` takes it.
127    #[must_use]
128    pub fn family(&self) -> FontFamily {
129        FontFamily::Quoted(quoted(self.family))
130    }
131
132    fn write(&self, out: &mut String) {
133        let family: Quoted = quoted(self.family);
134        let _ = write!(
135            out,
136            "@font-face{{font-family:{family};src:{};font-display:swap",
137            self.src
138        );
139        if let Some((min, max)) = self.weight {
140            let _ = write!(out, ";font-weight:{min} {max}");
141        }
142        if self.italic {
143            out.push_str(";font-style:italic");
144        }
145        out.push('}');
146    }
147}
148
149/// Rules for elements by their tag, and the fonts the page loads.
150///
151/// The rules sit in [`BASE_LAYER`], before the classes of
152/// [`Style`]s, so an element's own style overrides them. Conditions in a
153/// rule's style apply to the selector as they do to a class.
154///
155/// ```
156/// use hypersig_style::value::{Color, FontFamily, rem};
157/// use hypersig_style::{Element, Global, PseudoClass, Style};
158///
159/// let global = Global::preflight()
160///     .rule(Element::Body, Style::new().font_family(FontFamily::SystemUi))
161///     .rule(
162///         Element::A,
163///         Style::new().color(Color::CRIMSON).on(PseudoClass::Hover, Style::new().color(Color::RED)),
164///     );
165/// let css = global.to_css();
166/// assert!(css.contains("body{font-family:system-ui;line-height:1.5}"));
167/// assert!(css.contains("a:hover{color:red}"));
168/// # let _ = rem(1);
169/// ```
170#[derive(Debug, Clone, Default, PartialEq)]
171pub struct Global {
172    rules: Vec<(Selector, Style)>,
173    fonts: Vec<FontFace>,
174}
175
176impl Global {
177    /// No rules and no fonts.
178    #[must_use]
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// A small reset: `border-box` sizing everywhere, no default margins,
184    /// form controls in the page's font, and media that shrinks to its box.
185    #[must_use]
186    pub fn preflight() -> Self {
187        Self::new()
188            .rule(
189                Selector::Everything,
190                Style::new().box_sizing(BoxSizing::BorderBox).margin(px(0)),
191            )
192            .rule(
193                Element::Body,
194                Style::new().line_height(LineHeight::Number(1.5)),
195            )
196            .rule(
197                Element::Img,
198                Style::new().display(Display::Block).max_width(pct(100)),
199            )
200            .rule(
201                Element::Svg,
202                Style::new().display(Display::Block).max_width(pct(100)),
203            )
204            .rule(
205                Element::Video,
206                Style::new().display(Display::Block).max_width(pct(100)),
207            )
208            .rule(Element::Button, Self::inherit_font())
209            .rule(Element::Input, Self::inherit_font())
210            .rule(Element::Select, Self::inherit_font())
211            .rule(Element::Textarea, Self::inherit_font())
212    }
213
214    fn inherit_font() -> Style {
215        Style::new()
216            .keyword(Property::Font, CssWideKeyword::Inherit)
217            .color(Color::CURRENT_COLOR)
218    }
219
220    /// Adds `style` for every element `selector` matches. A later rule for the
221    /// same selector adds to the earlier one, and wins where both set a
222    /// property under the same conditions.
223    #[must_use]
224    pub fn rule(mut self, selector: impl Into<Selector>, style: Style) -> Self {
225        let selector = selector.into();
226        if let Some((_, old)) = self.rules.iter_mut().find(|(s, _)| *s == selector) {
227            *old = std::mem::take(old).merge(style);
228        } else {
229            self.rules.push((selector, style));
230        }
231        self
232    }
233
234    /// Loads `font`.
235    #[must_use]
236    pub fn font(mut self, font: FontFace) -> Self {
237        self.fonts.push(font);
238        self
239    }
240
241    /// Adds the rules and fonts of `global`, whose rules win over this one's.
242    #[must_use]
243    pub fn merge(mut self, global: Self) -> Self {
244        for (selector, style) in global.rules {
245            self = self.rule(selector, style);
246        }
247        self.fonts.extend(global.fonts);
248        self
249    }
250
251    /// Whether there are no rules and no fonts.
252    #[must_use]
253    pub fn is_empty(&self) -> bool {
254        self.rules.is_empty() && self.fonts.is_empty()
255    }
256
257    /// The rules in [`BASE_LAYER`], then the `@font-face` rules.
258    ///
259    /// # Panics
260    ///
261    /// Panics if a rule's style uses a pseudo-element with
262    /// [`Selector::Everything`], which already names `::before` and
263    /// `::after`, or a [`Condition::Group`](crate::Condition::Group) or
264    /// [`Condition::Peer`](crate::Condition::Peer), which need a marked
265    /// element the selector does not name.
266    #[must_use]
267    pub fn to_css(&self) -> String {
268        let mut out = format!("{ORDER}@layer {BASE_LAYER}{{");
269        for (selector, style) in &self.rules {
270            let mut sorted: Vec<_> = style.atoms().iter().collect();
271            sorted.sort_by(|a, b| {
272                (&a.conditions, a.property.rank(), a.property).cmp(&(
273                    &b.conditions,
274                    b.property.rank(),
275                    b.property,
276                ))
277            });
278            let mut i = 0;
279            while i < sorted.len() {
280                let conditions = &sorted[i].conditions;
281                assert!(
282                    !conditions.has_relation(),
283                    "a global rule takes no group or peer condition"
284                );
285                let mut suffix = String::new();
286                for (_, p) in &conditions.pseudo {
287                    suffix.push(':');
288                    suffix.push_str(p.name());
289                }
290                if let Some(e) = conditions.element {
291                    assert!(
292                        *selector != Selector::Everything,
293                        "Selector::Everything already covers ::before and ::after"
294                    );
295                    suffix.push_str("::");
296                    suffix.push_str(e.name());
297                }
298                let open = conditions.open(&mut out);
299                selector.write(&mut out, &suffix);
300                out.push('{');
301                let mut first = true;
302                while i < sorted.len() && sorted[i].conditions == *conditions {
303                    if !first {
304                        out.push(';');
305                    }
306                    first = false;
307                    sorted[i].property.write_name(&mut out);
308                    let _ = write!(out, ":{}", sorted[i].value);
309                    i += 1;
310                }
311                out.push('}');
312                Conditions::close(open, &mut out);
313            }
314        }
315        out.push('}');
316        for font in &self.fonts {
317            font.write(&mut out);
318        }
319        out
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::{Element, FontFace, Global, Selector};
326    use crate::value::{Color, url};
327    use crate::{Media, Style};
328
329    #[test]
330    fn writes_the_preflight_in_the_base_layer() {
331        let css = Global::preflight().to_css();
332        assert!(css.starts_with(
333            "@layer hypersig-tokens,hypersig-base,hypersig-atoms,hypersig-toggled;\
334             @layer hypersig-base{*,::before,::after{box-sizing:border-box;margin:0}"
335        ));
336        assert!(css.contains("button{color:currentColor;font:inherit}"));
337    }
338
339    #[test]
340    fn writes_media_conditions_and_fonts() {
341        let inter = FontFace::new("Inter", url("/inter.woff2")).weights(100, 900);
342        let css = Global::new()
343            .rule(
344                Selector::Root,
345                Style::new().on(Media::Dark, Style::new().color(Color::WHITE)),
346            )
347            .rule(Element::Body, Style::new().font_family(inter.family()))
348            .font(inter)
349            .to_css();
350        assert!(css.contains(
351            "@media (prefers-color-scheme: dark){:root{color:white}}body{font-family:\"Inter\"}}"
352        ));
353        assert!(css.ends_with(
354            "@font-face{font-family:\"Inter\";src:url(\"/inter.woff2\");font-display:swap;font-weight:100 900}"
355        ));
356    }
357}