Skip to main content

takumi_css/style/properties/
content.rs

1use cssparser::{Parser, Token, match_ignore_ascii_case};
2use std::{fmt, sync::Arc};
3
4use crate::style::{
5  Animatable, BackgroundImage, CssSyntaxKind, CssToken, FromCss, MakeComputed, ParseResult, ToCss,
6  properties::write_css_string, tw::TailwindPropertyParser, unexpected_token,
7};
8
9/// CSS `content` property value for `::before` / `::after` pseudo-elements.
10#[derive(Debug, Clone, Default, PartialEq)]
11pub enum ContentValue {
12  /// `content: normal`. For `::before` / `::after` this behaves as `None`.
13  #[default]
14  Normal,
15  /// `content: none`. Suppresses pseudo-element box generation.
16  None,
17  /// A non-empty list of generated content items.
18  Items(Box<[ContentItem]>),
19}
20
21/// A single item in a `content: ...` list.
22#[derive(Debug, Clone, PartialEq)]
23pub enum ContentItem {
24  /// A literal string, e.g. `content: "Hello"`.
25  Text(Arc<str>),
26  /// An image value: `url(...)`, `linear-gradient(...)`, etc.
27  Image(Box<BackgroundImage>),
28  /// `attr(name [, "fallback"])`, resolved at render-tree-build time against
29  /// the originating element's attributes.
30  Attr(AttrRef),
31}
32
33/// A parsed `attr(<name> [, <fallback>])` expression.
34#[derive(Debug, Clone, PartialEq)]
35pub struct AttrRef {
36  /// The attribute name (case-insensitive lookup).
37  pub name: Arc<str>,
38  /// Fallback string when the attribute is missing.
39  pub fallback: Arc<str>,
40}
41
42impl MakeComputed for ContentValue {
43  fn make_computed(&mut self, sizing: &crate::style::SizingContext) {
44    if let ContentValue::Items(items) = self {
45      for item in items.iter_mut() {
46        if let ContentItem::Image(image) = item {
47          image.as_mut().make_computed(sizing);
48        }
49      }
50    }
51  }
52}
53
54impl Animatable for ContentValue {}
55
56impl TailwindPropertyParser for ContentValue {
57  fn parse_tw(token: &str) -> Option<Self> {
58    match_ignore_ascii_case! {token,
59      "none" => Some(ContentValue::None),
60      "normal" => Some(ContentValue::Normal),
61      _ => None,
62    }
63  }
64}
65
66impl<'i> FromCss<'i> for ContentValue {
67  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
68    if input
69      .try_parse(|input| input.expect_ident_matching("none"))
70      .is_ok()
71    {
72      return Ok(ContentValue::None);
73    }
74
75    if input
76      .try_parse(|input| input.expect_ident_matching("normal"))
77      .is_ok()
78    {
79      return Ok(ContentValue::Normal);
80    }
81
82    let mut items = Vec::new();
83    while !input.is_exhausted() {
84      items.push(ContentItem::from_css(input)?);
85    }
86
87    if items.is_empty() {
88      let location = input.current_source_location();
89      return Err(unexpected_token!(Self, location, &Token::WhiteSpace("")));
90    }
91
92    Ok(ContentValue::Items(items.into_boxed_slice()))
93  }
94
95  const VALID_TOKENS: &'static [CssToken] = &[
96    CssToken::Keyword("none"),
97    CssToken::Keyword("normal"),
98    CssToken::Syntax(CssSyntaxKind::String),
99    CssToken::Syntax(CssSyntaxKind::Image),
100  ];
101}
102
103impl<'i> FromCss<'i> for ContentItem {
104  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
105    let start = input.state();
106    if let Ok(image) = input.try_parse(BackgroundImage::from_css) {
107      if !matches!(image, BackgroundImage::None) {
108        return Ok(ContentItem::Image(Box::new(image)));
109      }
110      // Bare `none` ident inside a list isn't a valid item; report it from
111      // the same position rather than letting it pass as an image.
112      input.reset(&start);
113    }
114
115    let location = input.current_source_location();
116    let token = input.next()?.clone();
117    match token {
118      Token::QuotedString(value) => Ok(ContentItem::Text(value.as_ref().into())),
119      Token::Function(ref name) if name.eq_ignore_ascii_case("attr") => input
120        .parse_nested_block(AttrRef::from_css)
121        .map(ContentItem::Attr),
122      other => Err(unexpected_token!(Self, location, &other)),
123    }
124  }
125
126  const VALID_TOKENS: &'static [CssToken] = &[
127    CssToken::Syntax(CssSyntaxKind::String),
128    CssToken::Syntax(CssSyntaxKind::Image),
129  ];
130}
131
132impl<'i> FromCss<'i> for AttrRef {
133  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
134    let name: Arc<str> = input.expect_ident()?.as_ref().into();
135    let fallback: Arc<str> = if input.try_parse(Parser::expect_comma).is_ok() {
136      input.expect_string()?.as_ref().into()
137    } else {
138      "".into()
139    };
140    Ok(Self { name, fallback })
141  }
142
143  const VALID_TOKENS: &'static [CssToken] = &[CssToken::Syntax(CssSyntaxKind::Ident)];
144}
145
146impl ToCss for ContentValue {
147  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
148    match self {
149      ContentValue::Normal => dest.write_str("normal"),
150      ContentValue::None => dest.write_str("none"),
151      ContentValue::Items(items) => {
152        for (i, item) in items.iter().enumerate() {
153          if i > 0 {
154            dest.write_char(' ')?;
155          }
156          item.to_css(dest)?;
157        }
158        Ok(())
159      }
160    }
161  }
162}
163
164impl ToCss for ContentItem {
165  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
166    match self {
167      ContentItem::Text(value) => write_css_string(dest, value),
168      ContentItem::Image(image) => image.to_css(dest),
169      ContentItem::Attr(attr) => attr.to_css(dest),
170    }
171  }
172}
173
174impl ToCss for AttrRef {
175  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
176    dest.write_str("attr(")?;
177    dest.write_str(&self.name)?;
178    if !self.fallback.is_empty() {
179      dest.write_str(", ")?;
180      write_css_string(dest, &self.fallback)?;
181    }
182    dest.write_char(')')
183  }
184}
185
186#[cfg(test)]
187#[allow(clippy::panic, clippy::expect_used)]
188mod tests {
189  use std::assert_matches;
190
191  use super::*;
192
193  fn parse(input: &str) -> ContentValue {
194    ContentValue::from_str(input).expect("parse")
195  }
196
197  #[test]
198  fn parses_none_and_normal() {
199    assert_eq!(parse("none"), ContentValue::None);
200    assert_eq!(parse("normal"), ContentValue::Normal);
201  }
202
203  #[test]
204  fn parses_single_string() {
205    let ContentValue::Items(items) = parse("\"hello\"") else {
206      panic!("expected items");
207    };
208    assert_eq!(items.len(), 1);
209    assert_eq!(items[0], ContentItem::Text("hello".into()));
210  }
211
212  #[test]
213  fn parses_multiple_strings_as_list() {
214    let ContentValue::Items(items) = parse("\"a\" \"b\"") else {
215      panic!("expected items");
216    };
217    assert_eq!(items.len(), 2);
218  }
219
220  #[test]
221  fn parses_attr_without_fallback() {
222    let ContentValue::Items(items) = parse("attr(label)") else {
223      panic!("expected items");
224    };
225    let ContentItem::Attr(attr) = &items[0] else {
226      panic!("expected attr");
227    };
228    assert_eq!(&*attr.name, "label");
229    assert_eq!(&*attr.fallback, "");
230  }
231
232  #[test]
233  fn parses_attr_with_fallback() {
234    let ContentValue::Items(items) = parse("attr(label, \"unknown\")") else {
235      panic!("expected items");
236    };
237    let ContentItem::Attr(attr) = &items[0] else {
238      panic!("expected attr");
239    };
240    assert_eq!(&*attr.fallback, "unknown");
241  }
242
243  #[test]
244  fn parses_url_image() {
245    let ContentValue::Items(items) = parse("url(\"icon.png\")") else {
246      panic!("expected items");
247    };
248    assert_matches!(
249      &items[0],
250      ContentItem::Image(image) if matches!(**image, BackgroundImage::Url(_))
251    );
252  }
253
254  #[test]
255  fn parses_mixed_list() {
256    let ContentValue::Items(items) = parse("\"Prefix: \" attr(name) url(\"icon.png\")") else {
257      panic!("expected items");
258    };
259    assert_eq!(items.len(), 3);
260  }
261
262  #[test]
263  fn unsupported_function_is_rejected() {
264    assert!(ContentValue::from_str("counter(foo)").is_err());
265    assert!(ContentValue::from_str("\"prefix\" counter(foo)").is_err());
266  }
267
268  #[test]
269  fn unsupported_keyword_is_rejected() {
270    assert!(ContentValue::from_str("open-quote").is_err());
271    assert!(ContentValue::from_str("\"x\" close-quote").is_err());
272  }
273}