1use proc_macro::TokenStream;
2use proc_macro2::{Span, TokenStream as TokenStream2, TokenTree};
3use quote::quote;
4use swc_common::{FileName, SourceMap};
5use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
6use syn::{
7 LitStr, Result, Token,
8 parse::{Parse, ParseStream},
9 parse_macro_input,
10 punctuated::Punctuated,
11};
12
13enum JsInput {
14 Literal(LitStr),
15 Tokens(TokenStream2),
16}
17
18impl Parse for JsInput {
19 fn parse(input: ParseStream) -> Result<Self> {
20 if input.peek(LitStr) {
21 let content: LitStr = input.parse()?;
22 Ok(JsInput::Literal(content))
23 } else {
24 let tokens: TokenStream2 = input.parse()?;
25 Ok(JsInput::Tokens(tokens))
26 }
27 }
28}
29
30enum CssInput {
31 Literal(LitStr),
32 Tokens(TokenStream2),
33}
34
35impl Parse for CssInput {
36 fn parse(input: ParseStream) -> Result<Self> {
37 if input.peek(LitStr) {
38 let content: LitStr = input.parse()?;
39 Ok(CssInput::Literal(content))
40 } else {
41 let tokens: TokenStream2 = input.parse()?;
42 Ok(CssInput::Tokens(tokens))
43 }
44 }
45}
46
47#[proc_macro]
48pub fn css(input: TokenStream) -> TokenStream {
49 let css_input = parse_macro_input!(input as CssInput);
50 let content_lit = match css_input {
51 CssInput::Literal(content) => content,
52 CssInput::Tokens(tokens) => {
53 let css = tokens_to_css(tokens);
54 if let Err(message) = validate_css(&css) {
55 return syn::Error::new(Span::call_site(), message)
56 .to_compile_error()
57 .into();
58 }
59 LitStr::new(&css, Span::call_site())
60 }
61 };
62
63 let output = quote! {
64
65 pub fn callsite_id(prefix: &str, file: &str, line: u32, col: u32) -> String {
66 let mut h: u64 = 0xcbf29ce484222325; for b in file.as_bytes() {
69 h ^= *b as u64;
70 h = h.wrapping_mul(0x100000001b3);
71 }
72 for b in line.to_le_bytes() {
73 h ^= b as u64;
74 h = h.wrapping_mul(0x100000001b3);
75 }
76 for b in col.to_le_bytes() {
77 h ^= b as u64;
78 h = h.wrapping_mul(0x100000001b3);
79 }
80
81 format!("{prefix}{h:016x}")
83 }
84
85 let __id = callsite_id(
86 "mx-css-",
87 file!(),
88 line!(),
89 column!(),
90 );
91
92 maud::html! {
93 style data-mx-css-id=(__id) {
94 (maud::PreEscaped(#content_lit))
95 }
96 }
97 };
98
99 TokenStream::from(output)
100}
101
102fn tokens_to_css(tokens: TokenStream2) -> String {
103 let mut out = String::new();
104 let mut prev_word = false;
105
106 for token in tokens {
107 match token {
108 TokenTree::Group(group) => {
109 let (open, close) = match group.delimiter() {
110 proc_macro2::Delimiter::Parenthesis => ('(', ')'),
111 proc_macro2::Delimiter::Bracket => ('[', ']'),
112 proc_macro2::Delimiter::Brace => ('{', '}'),
113 proc_macro2::Delimiter::None => (' ', ' '),
114 };
115 let needs_space = prev_word
116 && matches!(
117 group.delimiter(),
118 proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
119 );
120 if needs_space {
121 out.push(' ');
122 }
123 if open != ' ' {
124 out.push(open);
125 }
126 out.push_str(&tokens_to_css(group.stream()));
127 if close != ' ' {
128 out.push(close);
129 }
130 prev_word = false;
131 }
132 TokenTree::Ident(ident) => {
133 if prev_word {
134 out.push(' ');
135 }
136 out.push_str(&ident.to_string());
137 prev_word = true;
138 }
139 TokenTree::Literal(literal) => {
140 if prev_word {
141 out.push(' ');
142 }
143 out.push_str(&literal.to_string());
144 prev_word = true;
145 }
146 TokenTree::Punct(punct) => {
147 out.push(punct.as_char());
148 prev_word = false;
149 }
150 }
151 }
152
153 out
154}
155
156fn validate_css(css: &str) -> core::result::Result<(), String> {
157 let mut input = cssparser::ParserInput::new(css);
158 let mut parser = cssparser::Parser::new(&mut input);
159 loop {
160 match parser.next_including_whitespace_and_comments() {
161 Ok(_) => {}
162 Err(err) => match err.kind {
163 cssparser::BasicParseErrorKind::EndOfInput => return Ok(()),
164 _ => return Err("css! could not parse CSS tokens".to_string()),
165 },
166 }
167 }
168}
169
170#[proc_macro]
171pub fn js(input: TokenStream) -> TokenStream {
172 let js_input = parse_macro_input!(input as JsInput);
173 let (content_lit, js_string) = match js_input {
174 JsInput::Literal(content) => {
175 let js_string = content.value();
176 (content, js_string)
177 }
178 JsInput::Tokens(tokens) => {
179 let js = tokens_to_js(tokens);
180 (LitStr::new(&js, Span::call_site()), js)
181 }
182 };
183 if let Err(message) = validate_js(&js_string) {
184 return syn::Error::new(Span::call_site(), message)
185 .to_compile_error()
186 .into();
187 }
188
189 let output = quote! {
190 maud::html! {
191 script {
192 (maud::PreEscaped(#content_lit))
193 }
194 }
195 };
196
197 TokenStream::from(output)
198}
199
200#[proc_macro]
201pub fn inline_js(input: TokenStream) -> TokenStream {
202 let tokens: TokenStream2 = input.into();
203 let output = quote! {
204 fn js() -> maud::Markup {
205 ::maud_extensions::js! { #tokens }
206 }
207 };
208
209 TokenStream::from(output)
210}
211
212#[proc_macro]
213pub fn inline_css(input: TokenStream) -> TokenStream {
214 let tokens: TokenStream2 = input.into();
215 let output = quote! {
216 fn css() -> maud::Markup {
217 ::maud_extensions::css! { #tokens }
218 }
219 };
220
221 TokenStream::from(output)
222}
223
224fn tokens_to_js(tokens: TokenStream2) -> String {
225 let mut out = String::new();
226 let mut prev_word = false;
227
228 for token in tokens {
229 match token {
230 TokenTree::Group(group) => {
231 let (open, close) = match group.delimiter() {
232 proc_macro2::Delimiter::Parenthesis => ('(', ')'),
233 proc_macro2::Delimiter::Bracket => ('[', ']'),
234 proc_macro2::Delimiter::Brace => ('{', '}'),
235 proc_macro2::Delimiter::None => (' ', ' '),
236 };
237 let needs_space = prev_word
238 && matches!(
239 group.delimiter(),
240 proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
241 );
242 if needs_space {
243 out.push(' ');
244 }
245 if open != ' ' {
246 out.push(open);
247 }
248 out.push_str(&tokens_to_js(group.stream()));
249 if close != ' ' {
250 out.push(close);
251 }
252 prev_word = false;
253 }
254 TokenTree::Ident(ident) => {
255 if prev_word {
256 out.push(' ');
257 }
258 out.push_str(&ident.to_string());
259 prev_word = true;
260 }
261 TokenTree::Literal(literal) => {
262 if prev_word {
263 out.push(' ');
264 }
265 out.push_str(&literal.to_string());
266 prev_word = true;
267 }
268 TokenTree::Punct(punct) => {
269 out.push(punct.as_char());
270 prev_word = false;
271 }
272 }
273 }
274
275 out
276}
277
278fn validate_js(js: &str) -> core::result::Result<(), String> {
279 let cm = SourceMap::default();
280 let fm = cm.new_source_file(
281 FileName::Custom("inline.js".to_string()).into(),
282 js.to_string(),
283 );
284 let input = StringInput::from(&*fm);
285 let mut parser = Parser::new(Syntax::Es(EsSyntax::default()), input, None);
286 match parser.parse_script() {
287 Ok(_) => Ok(()),
288 Err(err) => Err(format!("js! could not parse JavaScript: {err:#?}")),
289 }
290}
291
292struct FontFace {
293 path: LitStr,
294 family: LitStr,
295 weight: Option<LitStr>,
296 style: Option<LitStr>,
297}
298
299impl Parse for FontFace {
300 fn parse(input: ParseStream) -> syn::Result<Self> {
301 let path: LitStr = input.parse()?;
302 input.parse::<Token![,]>()?;
303 let family: LitStr = input.parse()?;
304
305 let weight = if input.peek(Token![,]) {
306 input.parse::<Token![,]>()?;
307 if input.peek(LitStr) {
308 Some(input.parse()?)
309 } else {
310 None
311 }
312 } else {
313 None
314 };
315
316 let style = if weight.is_some() && input.peek(Token![,]) {
317 input.parse::<Token![,]>()?;
318 if input.peek(LitStr) {
319 Some(input.parse()?)
320 } else {
321 None
322 }
323 } else {
324 None
325 };
326
327 Ok(FontFace {
328 path,
329 family,
330 weight,
331 style,
332 })
333 }
334}
335
336struct FontFaceList {
337 fonts: Punctuated<FontFace, Token![;]>,
338}
339
340impl Parse for FontFaceList {
341 fn parse(input: ParseStream) -> syn::Result<Self> {
342 let fonts = Punctuated::parse_terminated(input)?;
343 Ok(FontFaceList { fonts })
344 }
345}
346
347#[proc_macro]
348pub fn font_face(input: TokenStream) -> TokenStream {
349 let font = parse_macro_input!(input as FontFace);
350
351 let path = font.path;
352 let family = font.family;
353 let weight = font
354 .weight
355 .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
356 let style = font
357 .style
358 .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
359
360 let expanded = quote! {
361 {
362 use base64::Engine;
363 use base64::engine::general_purpose::STANDARD;
364 use maud::PreEscaped;
365
366 let font_bytes = include_bytes!(#path);
367 let mut base64_string = String::new();
368
369 STANDARD.encode_string(font_bytes, &mut base64_string);
370
371 let path_str = #path;
372 let format = if path_str.ends_with(".ttf") {
373 "truetype"
374 } else if path_str.ends_with(".otf") {
375 "opentype"
376 } else if path_str.ends_with(".woff") {
377 "woff"
378 } else if path_str.ends_with(".woff2") {
379 "woff2"
380 } else {
381 "truetype"
382 };
383
384 let font_type = if path_str.ends_with(".woff2") {
385 "woff2"
386 } else if path_str.ends_with(".woff") {
387 "woff"
388 } else if path_str.ends_with(".otf") {
389 "opentype"
390 } else {
391 "truetype"
392 };
393
394 let css = format!(
395 "@font-face {{\n font-family: '{}';\n src: url('data:font/{};base64,{}') format('{}');\n font-weight: {};\n font-style: {};\n}}",
396 #family,
397 font_type,
398 base64_string,
399 format,
400 #weight,
401 #style
402 );
403
404 PreEscaped(css)
405 }
406 };
407
408 expanded.into()
409}
410
411#[proc_macro]
412pub fn font_faces(input: TokenStream) -> TokenStream {
413 let fonts = parse_macro_input!(input as FontFaceList);
414
415 let font_faces = fonts.fonts.iter().map(|font| {
416 let path = &font.path;
417 let family = &font.family;
418 let weight = font
419 .weight
420 .as_ref()
421 .map_or_else(|| quote! { "normal" }, |w| quote! { #w });
422 let style = font
423 .style
424 .as_ref()
425 .map_or_else(|| quote! { "normal" }, |s| quote! { #s });
426
427 quote! {
428 {
429 use maud_extensions::font_face;
430 let face = font_face!(#path, #family, #weight, #style);
431 css.push_str(&face.0);
432 }
433 }
434 });
435
436 let expanded = quote! {
437 {
438 use maud::PreEscaped;
439 let mut css = String::new();
440
441 #(#font_faces)*
442
443 PreEscaped(css)
444 }
445 };
446
447 expanded.into()
448}