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