1use proc_macro::TokenStream;
2use proc_macro2::{Delimiter, Group, Ident, Span, TokenStream as TokenStream2, TokenTree};
3use quote::quote;
4use swc_common::{FileName, SourceMap};
5use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
6use syn::{
7 Expr, LitStr, Result, Token,
8 parse::{Nothing, Parse, ParseStream},
9 parse_macro_input,
10 punctuated::Punctuated,
11};
12
13const SURREAL_JS_BUNDLE: &str = include_str!("../assets/surreal.js");
14const CSS_SCOPE_INLINE_JS_BUNDLE: &str = include_str!("../assets/css-scope-inline.js");
15const COMPONENT_JS_HELPER_FN: &str = "__maud_extensions_component_requires_js_macro_in_scope_can_be_empty";
16const COMPONENT_CSS_HELPER_FN: &str = "__maud_extensions_component_requires_css_macro_in_scope_can_be_empty";
17
18enum JsInput {
19 Literal(LitStr),
20 Tokens(TokenStream2),
21}
22
23impl Parse for JsInput {
24 fn parse(input: ParseStream) -> Result<Self> {
25 if input.peek(LitStr) {
26 let content: LitStr = input.parse()?;
27 Ok(JsInput::Literal(content))
28 } else {
29 let tokens: TokenStream2 = input.parse()?;
30 Ok(JsInput::Tokens(tokens))
31 }
32 }
33}
34
35enum CssInput {
36 Literal(LitStr),
37 Tokens(TokenStream2),
38}
39
40impl Parse for CssInput {
41 fn parse(input: ParseStream) -> Result<Self> {
42 if input.peek(LitStr) {
43 let content: LitStr = input.parse()?;
44 Ok(CssInput::Literal(content))
45 } else {
46 let tokens: TokenStream2 = input.parse()?;
47 Ok(CssInput::Tokens(tokens))
48 }
49 }
50}
51
52fn expand_css_markup(css_input: CssInput) -> TokenStream {
53 let content_lit = match css_input {
54 CssInput::Literal(content) => content,
55 CssInput::Tokens(tokens) => {
56 let css = tokens_to_css(tokens);
57 if let Err(message) = validate_css(&css) {
58 return syn::Error::new(Span::call_site(), message)
59 .to_compile_error()
60 .into();
61 }
62 LitStr::new(&css, Span::call_site())
63 }
64 };
65
66 let output = quote! {
67 {
68 fn callsite_id(prefix: &str, file: &str, line: u32, col: u32) -> String {
69 let mut h: u64 = 0xcbf29ce484222325; for b in file.as_bytes() {
72 h ^= *b as u64;
73 h = h.wrapping_mul(0x100000001b3);
74 }
75 for b in line.to_le_bytes() {
76 h ^= b as u64;
77 h = h.wrapping_mul(0x100000001b3);
78 }
79 for b in col.to_le_bytes() {
80 h ^= b as u64;
81 h = h.wrapping_mul(0x100000001b3);
82 }
83
84 format!("{prefix}{h:016x}")
86 }
87
88 let __id = callsite_id(
89 "mx-css-",
90 file!(),
91 line!(),
92 column!(),
93 );
94
95 maud::html! {
96 style data-mx-css-id=(__id) {
97 (maud::PreEscaped(#content_lit))
98 }
99 }
100 }
101 };
102
103 TokenStream::from(output)
104}
105
106fn expand_css_helper(tokens: TokenStream2) -> TokenStream {
107 let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
108 let output = quote! {
109 fn css() -> maud::Markup {
110 ::maud_extensions::inline_css! { #tokens }
111 }
112
113 #[doc(hidden)]
114 fn #component_css_helper_ident() -> maud::Markup {
115 css()
116 }
117 };
118
119 TokenStream::from(output)
120}
121
122#[proc_macro]
123pub fn css(input: TokenStream) -> TokenStream {
124 let tokens: TokenStream2 = input.into();
125 expand_css_helper(tokens)
126}
127
128fn tokens_to_css(tokens: TokenStream2) -> String {
129 let mut out = String::new();
130 let mut prev_word = false;
131
132 for token in tokens {
133 match token {
134 TokenTree::Group(group) => {
135 let (open, close) = match group.delimiter() {
136 proc_macro2::Delimiter::Parenthesis => ('(', ')'),
137 proc_macro2::Delimiter::Bracket => ('[', ']'),
138 proc_macro2::Delimiter::Brace => ('{', '}'),
139 proc_macro2::Delimiter::None => (' ', ' '),
140 };
141 let needs_space = prev_word
142 && matches!(
143 group.delimiter(),
144 proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
145 );
146 if needs_space {
147 out.push(' ');
148 }
149 if open != ' ' {
150 out.push(open);
151 }
152 out.push_str(&tokens_to_css(group.stream()));
153 if close != ' ' {
154 out.push(close);
155 }
156 prev_word = false;
157 }
158 TokenTree::Ident(ident) => {
159 if prev_word {
160 out.push(' ');
161 }
162 out.push_str(&ident.to_string());
163 prev_word = true;
164 }
165 TokenTree::Literal(literal) => {
166 if prev_word {
167 out.push(' ');
168 }
169 out.push_str(&literal.to_string());
170 prev_word = true;
171 }
172 TokenTree::Punct(punct) => {
173 out.push(punct.as_char());
174 prev_word = false;
175 }
176 }
177 }
178
179 out
180}
181
182fn validate_css(css: &str) -> core::result::Result<(), String> {
183 let mut input = cssparser::ParserInput::new(css);
184 let mut parser = cssparser::Parser::new(&mut input);
185 loop {
186 match parser.next_including_whitespace_and_comments() {
187 Ok(_) => {}
188 Err(err) => match err.kind {
189 cssparser::BasicParseErrorKind::EndOfInput => return Ok(()),
190 _ => return Err("inline_css! could not parse CSS tokens".to_string()),
191 },
192 }
193 }
194}
195
196fn expand_js_markup(js_input: JsInput) -> TokenStream {
197 let (content_lit, js_string) = match js_input {
198 JsInput::Literal(content) => {
199 let js_string = content.value();
200 (content, js_string)
201 }
202 JsInput::Tokens(tokens) => {
203 let js = tokens_to_js(tokens);
204 (LitStr::new(&js, Span::call_site()), js)
205 }
206 };
207 if let Err(message) = validate_js(&js_string) {
208 return syn::Error::new(Span::call_site(), message)
209 .to_compile_error()
210 .into();
211 }
212
213 let output = quote! {
214 maud::html! {
215 script {
216 (maud::PreEscaped(#content_lit))
217 }
218 }
219 };
220
221 TokenStream::from(output)
222}
223
224fn expand_js_helper(tokens: TokenStream2) -> TokenStream {
225 let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
226 let output = quote! {
227 fn js() -> maud::Markup {
228 ::maud_extensions::inline_js! { #tokens }
229 }
230
231 #[doc(hidden)]
232 fn #component_js_helper_ident() -> maud::Markup {
233 js()
234 }
235 };
236
237 TokenStream::from(output)
238}
239
240#[proc_macro]
241pub fn js(input: TokenStream) -> TokenStream {
242 let tokens: TokenStream2 = input.into();
243 expand_js_helper(tokens)
244}
245
246#[proc_macro]
247pub fn inline_js(input: TokenStream) -> TokenStream {
248 let js_input = parse_macro_input!(input as JsInput);
249 expand_js_markup(js_input)
250}
251
252#[proc_macro]
253pub fn inline_css(input: TokenStream) -> TokenStream {
254 let css_input = parse_macro_input!(input as CssInput);
255 expand_css_markup(css_input)
256}
257
258fn component_syntax_error() -> syn::Error {
259 syn::Error::new(
260 Span::call_site(),
261 "component! expects exactly one top-level element with a body block, e.g. component! { article { ... } }",
262 )
263}
264
265#[proc_macro]
266pub fn component(input: TokenStream) -> TokenStream {
267 let component_js_helper_ident = Ident::new(COMPONENT_JS_HELPER_FN, Span::call_site());
268 let component_css_helper_ident = Ident::new(COMPONENT_CSS_HELPER_FN, Span::call_site());
269 let mut tokens: Vec<TokenTree> = TokenStream2::from(input).into_iter().collect();
270
271 while matches!(
272 tokens.last(),
273 Some(TokenTree::Punct(punct)) if punct.as_char() == ';'
274 ) {
275 tokens.pop();
276 }
277
278 if tokens.is_empty() {
279 return component_syntax_error().to_compile_error().into();
280 }
281
282 if !matches!(tokens.first(), Some(TokenTree::Ident(_))) {
283 return component_syntax_error().to_compile_error().into();
284 }
285
286 let root_body_count = tokens
287 .iter()
288 .filter(|token| matches!(token, TokenTree::Group(group) if group.delimiter() == Delimiter::Brace))
289 .count();
290
291 if root_body_count != 1 {
292 return component_syntax_error().to_compile_error().into();
293 }
294
295 let Some(TokenTree::Group(root_group)) = tokens.last() else {
296 return component_syntax_error().to_compile_error().into();
297 };
298 if root_group.delimiter() != Delimiter::Brace {
299 return component_syntax_error().to_compile_error().into();
300 }
301
302 let mut injected_body = root_group.stream();
303 injected_body.extend(quote! { (#component_js_helper_ident()) (#component_css_helper_ident()) });
304 let mut updated_group = Group::new(Delimiter::Brace, injected_body);
305 updated_group.set_span(root_group.span());
306 let last_index = tokens.len() - 1;
307 tokens[last_index] = TokenTree::Group(updated_group);
308
309 let root_tokens: TokenStream2 = tokens.into_iter().collect();
310 let output = quote! {
311 maud::html! {
312 #root_tokens
313 }
314 };
315
316 output.into()
317}
318
319#[proc_macro]
320pub fn js_file(input: TokenStream) -> TokenStream {
321 let path = parse_macro_input!(input as Expr);
322 let output = quote! {
323 maud::html! {
324 script {
325 (maud::PreEscaped(include_str!(#path)))
326 }
327 }
328 };
329
330 TokenStream::from(output)
331}
332
333#[proc_macro]
334pub fn css_file(input: TokenStream) -> TokenStream {
335 let path = parse_macro_input!(input as Expr);
336 let output = quote! {
337 maud::html! {
338 style {
339 (maud::PreEscaped(include_str!(#path)))
340 }
341 }
342 };
343
344 TokenStream::from(output)
345}
346
347#[proc_macro]
348pub fn surreal_scope_inline(input: TokenStream) -> TokenStream {
349 let _ = parse_macro_input!(input as Nothing);
350 let surreal_js = LitStr::new(SURREAL_JS_BUNDLE, Span::call_site());
351 let css_scope_inline_js = LitStr::new(CSS_SCOPE_INLINE_JS_BUNDLE, Span::call_site());
352 let output = quote! {
353 maud::html! {
354 script {
355 (maud::PreEscaped(#surreal_js))
356 }
357 script {
358 (maud::PreEscaped(#css_scope_inline_js))
359 }
360 }
361 };
362
363 TokenStream::from(output)
364}
365
366fn tokens_to_js(tokens: TokenStream2) -> String {
367 let mut out = String::new();
368 let mut prev_word = false;
369
370 for token in tokens {
371 match token {
372 TokenTree::Group(group) => {
373 let (open, close) = match group.delimiter() {
374 proc_macro2::Delimiter::Parenthesis => ('(', ')'),
375 proc_macro2::Delimiter::Bracket => ('[', ']'),
376 proc_macro2::Delimiter::Brace => ('{', '}'),
377 proc_macro2::Delimiter::None => (' ', ' '),
378 };
379 let needs_space = prev_word
380 && matches!(
381 group.delimiter(),
382 proc_macro2::Delimiter::Brace | proc_macro2::Delimiter::None
383 );
384 if needs_space {
385 out.push(' ');
386 }
387 if open != ' ' {
388 out.push(open);
389 }
390 out.push_str(&tokens_to_js(group.stream()));
391 if close != ' ' {
392 out.push(close);
393 }
394 prev_word = false;
395 }
396 TokenTree::Ident(ident) => {
397 if prev_word {
398 out.push(' ');
399 }
400 out.push_str(&ident.to_string());
401 prev_word = true;
402 }
403 TokenTree::Literal(literal) => {
404 if prev_word {
405 out.push(' ');
406 }
407 out.push_str(&literal.to_string());
408 prev_word = true;
409 }
410 TokenTree::Punct(punct) => {
411 out.push(punct.as_char());
412 prev_word = false;
413 }
414 }
415 }
416
417 out
418}
419
420fn validate_js(js: &str) -> core::result::Result<(), String> {
421 let cm = SourceMap::default();
422 let fm = cm.new_source_file(
423 FileName::Custom("inline.js".to_string()).into(),
424 js.to_string(),
425 );
426 let input = StringInput::from(&*fm);
427 let mut parser = Parser::new(Syntax::Es(EsSyntax::default()), input, None);
428 match parser.parse_script() {
429 Ok(_) => Ok(()),
430 Err(err) => Err(format!("inline_js! could not parse JavaScript: {err:#?}")),
431 }
432}
433
434struct FontFace {
435 path: LitStr,
436 family: LitStr,
437 weight: Option<LitStr>,
438 style: Option<LitStr>,
439}
440
441impl Parse for FontFace {
442 fn parse(input: ParseStream) -> syn::Result<Self> {
443 let path: LitStr = input.parse()?;
444 input.parse::<Token![,]>()?;
445 let family: LitStr = input.parse()?;
446
447 let weight = if input.peek(Token![,]) {
448 input.parse::<Token![,]>()?;
449 if input.peek(LitStr) {
450 Some(input.parse()?)
451 } else {
452 None
453 }
454 } else {
455 None
456 };
457
458 let style = if weight.is_some() && input.peek(Token![,]) {
459 input.parse::<Token![,]>()?;
460 if input.peek(LitStr) {
461 Some(input.parse()?)
462 } else {
463 None
464 }
465 } else {
466 None
467 };
468
469 Ok(FontFace {
470 path,
471 family,
472 weight,
473 style,
474 })
475 }
476}
477
478struct FontFaceList {
479 fonts: Punctuated<FontFace, Token![;]>,
480}
481
482impl Parse for FontFaceList {
483 fn parse(input: ParseStream) -> syn::Result<Self> {
484 let fonts = Punctuated::parse_terminated(input)?;
485 Ok(FontFaceList { fonts })
486 }
487}
488
489#[proc_macro]
490pub fn font_face(input: TokenStream) -> TokenStream {
491 let font = parse_macro_input!(input as FontFace);
492
493 let path = font.path;
494 let family = font.family;
495 let weight = font
496 .weight
497 .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
498 let style = font
499 .style
500 .unwrap_or_else(|| LitStr::new("normal", Span::call_site()));
501
502 let expanded = quote! {
503 {
504 use base64::Engine;
505 use base64::engine::general_purpose::STANDARD;
506 use maud::PreEscaped;
507
508 let font_bytes = include_bytes!(#path);
509 let mut base64_string = String::new();
510
511 STANDARD.encode_string(font_bytes, &mut base64_string);
512
513 let path_str = #path;
514 let format = if path_str.ends_with(".ttf") {
515 "truetype"
516 } else if path_str.ends_with(".otf") {
517 "opentype"
518 } else if path_str.ends_with(".woff") {
519 "woff"
520 } else if path_str.ends_with(".woff2") {
521 "woff2"
522 } else {
523 "truetype"
524 };
525
526 let font_type = if path_str.ends_with(".woff2") {
527 "woff2"
528 } else if path_str.ends_with(".woff") {
529 "woff"
530 } else if path_str.ends_with(".otf") {
531 "opentype"
532 } else {
533 "truetype"
534 };
535
536 let css = format!(
537 "@font-face {{\n font-family: '{}';\n src: url('data:font/{};base64,{}') format('{}');\n font-weight: {};\n font-style: {};\n}}",
538 #family,
539 font_type,
540 base64_string,
541 format,
542 #weight,
543 #style
544 );
545
546 PreEscaped(css)
547 }
548 };
549
550 expanded.into()
551}
552
553#[proc_macro]
554pub fn font_faces(input: TokenStream) -> TokenStream {
555 let fonts = parse_macro_input!(input as FontFaceList);
556
557 let font_faces = fonts.fonts.iter().map(|font| {
558 let path = &font.path;
559 let family = &font.family;
560 let weight = font
561 .weight
562 .as_ref()
563 .map_or_else(|| quote! { "normal" }, |w| quote! { #w });
564 let style = font
565 .style
566 .as_ref()
567 .map_or_else(|| quote! { "normal" }, |s| quote! { #s });
568
569 quote! {
570 {
571 use maud_extensions::font_face;
572 let face = font_face!(#path, #family, #weight, #style);
573 css.push_str(&face.0);
574 }
575 }
576 });
577
578 let expanded = quote! {
579 {
580 use maud::PreEscaped;
581 let mut css = String::new();
582
583 #(#font_faces)*
584
585 PreEscaped(css)
586 }
587 };
588
589 expanded.into()
590}