yew_interop_macro/
lib.rs

1mod kw;
2#[cfg(feature = "script")]
3mod script;
4mod url;
5
6#[cfg(feature = "script")]
7use script::EffectScriptEntry;
8
9use crate::url::{LibraryUrl, UrlInput};
10use itertools::{izip, Either};
11use proc_macro::TokenStream;
12use proc_macro2::Span;
13use quote::quote;
14
15use syn::parse::{Parse, ParseStream};
16
17use syn::{
18    parse_macro_input, Error as SynError, Expr, ExprLit, Ident, Lit, LitInt, LitStr,
19    Result as SynResult,
20};
21use yew_interop_core::LinkType;
22
23#[cfg(feature = "script")]
24use syn::Token;
25
26struct ResourceDeclaration {
27    idents: Vec<Ident>,
28    link_groups: Vec<Vec<LibraryUrl>>,
29    #[cfg(feature = "script")]
30    effect_scripts: Vec<EffectScriptEntry>,
31}
32
33#[cfg(feature = "script")]
34enum NextEntry {
35    EffectScript,
36    Lib,
37}
38
39#[cfg(feature = "script")]
40fn peek_script_or_lib(input: ParseStream) -> SynResult<NextEntry> {
41    let lookahead = input.lookahead1();
42
43    lookahead
44        .peek(Token![!])
45        .then(|| NextEntry::EffectScript)
46        .or_else(|| lookahead.peek(Ident).then(|| NextEntry::Lib))
47        .ok_or_else(|| lookahead.error())
48}
49
50fn parse_library_urls(input: ParseStream) -> SynResult<Vec<LibraryUrl>> {
51    let mut urls = Vec::new();
52    loop {
53        if input.peek(kw::js) {
54            input.parse::<kw::js>().unwrap();
55            let expr = input.parse::<Expr>()?;
56            urls.push(LibraryUrl::new(
57                UrlInput::TypeSpecified(Box::new(expr)),
58                LinkType::Js,
59            ))
60        } else if input.peek(kw::css) {
61            input.parse::<kw::css>().unwrap();
62            let expr = input.parse::<Expr>()?;
63            urls.push(LibraryUrl::new(
64                UrlInput::TypeSpecified(Box::new(expr)),
65                LinkType::Css,
66            ))
67        } else if input.peek(LitStr) {
68            urls.push(LibraryUrl::try_from(input.parse::<LitStr>().unwrap())?);
69        } else {
70            break;
71        }
72    }
73    Ok(urls)
74}
75
76impl Parse for ResourceDeclaration {
77    fn parse(input: ParseStream) -> SynResult<Self> {
78        let mut idents = Vec::new();
79        let mut link_groups = Vec::new();
80
81        #[cfg(feature = "script")]
82        let mut effect_scripts = Vec::new();
83
84        while !input.is_empty() {
85            #[cfg(feature = "script")]
86            match peek_script_or_lib(input)? {
87                NextEntry::EffectScript => {
88                    let entry = EffectScriptEntry::parse(input)?;
89                    effect_scripts.push(entry)
90                }
91                NextEntry::Lib => {
92                    Self::parse_library(input, &mut idents, &mut link_groups)?;
93                }
94            }
95            #[cfg(not(feature = "script"))]
96            Self::parse_library(input, &mut idents, &mut link_groups)?;
97        }
98
99        Ok(Self {
100            idents,
101            link_groups,
102
103            #[cfg(feature = "script")]
104            effect_scripts,
105        })
106    }
107}
108
109impl ResourceDeclaration {
110    fn parse_library(
111        input: ParseStream,
112        idents: &mut Vec<Ident>,
113        link_groups: &mut Vec<Vec<LibraryUrl>>,
114    ) -> SynResult<()> {
115        let ident = input.parse::<Ident>().unwrap();
116        idents.push(ident);
117        link_groups.push(parse_library_urls(input)?);
118        Ok(())
119    }
120}
121
122/// Declare your libraries as whitespace separated groups of identifier
123/// and one or more urls
124///
125/// # Example 1
126///
127/// ```
128/// # mod a{
129/// # use yew_interop::declare_resources;
130/// declare_resources!(my_library "https://cdn.com/my_library.js");
131/// # }
132/// ```
133///
134/// # Example 2
135///
136/// ```
137/// # mod a {
138/// # use yew_interop::declare_resources;
139/// declare_resources!(
140/// library_one
141/// "https://cdn.com/a.js"
142/// "https://cdn.com/b.css"
143/// "https://cdn.com/c.css"
144/// library_two
145/// "https://cdn.com/b.css"
146/// );
147/// # }
148/// ```
149///
150/// # Explicitly Specify the Url Type
151///
152/// the macro needs to know whether the url is JavaScript or CSS.
153/// When you provide a string literal as the examples above,
154/// the macro derives the information from the suffix (either .js or .css).
155/// When the string literal doesn't end with .js or .css,
156/// or when you provide other expressions like a macro call or a identifier,
157/// you need to manually specify the URL type by prepending the custom keyword js/css
158/// before the url.
159///
160/// ```
161///
162/// # mod a {
163/// # use yew_interop::declare_resources;
164/// const MY_CSS_URL: &str = "https://my_static.com/some.css";
165///
166/// /// production/dev aware static url
167/// fn static_url(rel: &'static str) -> String{
168///     if cfg!(debug_assertions){
169///         rel.to_string()
170///     }else{
171///         format!("https://static.my-cdn.com/{}", rel)
172///     }
173/// }
174/// declare_resources!(
175/// my_library
176/// css MY_CSS_URL
177/// js static_url("my_other_library")
178/// );
179/// # }
180///
181/// ```
182///
183/// The macro expect the return type of the expression to implement `Into<Cow<'static, str>>`,
184/// `&'static str`, `String` and `Cow<'static, str>` are all valid types for example.
185///
186/// # Side Effect Scripts
187///
188/// To declare a side effect script, just prepend the identifier with an exclamation mark (!),
189/// note the script has to be in JavaScript, so no type should be explicitly specified.
190///
191///
192/// ```
193/// # #[cfg(feature = "script")]
194/// # mod a {
195/// # use yew_interop::declare_resources;
196/// declare_resources!(
197/// my_library
198/// "https://cdn.com/lib.js"
199/// ! my_effect
200/// "https://cdn.com/effect.js"
201/// );
202/// # }
203///
204/// ```
205///
206/// # Consumption
207///
208/// The macro expands into a `<ResourceProvider/>` component and hook functions for each of your
209/// resources.
210/// The names of the hook functions are `use_<resource_identifier>`.
211/// Example 2 above will expand into two hook functions `pub fn use_library_one()`
212/// and `pub fn use_library_two()`
213///
214/// You should wrap the root component of your app in the `<ResourceProvider/>` like this:
215/// ```
216/// # use yew::prelude::*;
217/// # use yew_interop::declare_resources;
218/// # #[function_component(App)]
219/// # fn app() -> Html{
220/// # html!{}
221/// # }
222/// # declare_resources!(
223/// # my_library
224/// # "https://cdn.com/lib.js"
225/// # );
226/// html!{
227///     <ResourceProvider>
228///         <App/>
229///     </ResourceProvider>
230/// };
231/// ```
232///
233/// The hooks are to be used in the consuming components.
234#[proc_macro]
235pub fn declare_resources(input: TokenStream) -> TokenStream {
236    let resource_declaration = parse_macro_input!(input as ResourceDeclaration);
237
238    #[cfg(not(feature = "script"))]
239    let ResourceDeclaration {
240        idents,
241        link_groups,
242    } = resource_declaration;
243
244    #[cfg(feature = "script")]
245    let ResourceDeclaration {
246        idents,
247        link_groups,
248        effect_scripts,
249    } = resource_declaration;
250
251    #[cfg(feature = "script")]
252    let (script_hooks, script_handle_enums, script_urls, script_loaders, script_handles): (
253        Vec<_>,
254        Vec<_>,
255        Vec<_>,
256        Vec<_>,
257        Vec<_>,
258    ) = itertools::multiunzip(effect_scripts.into_iter().map(
259        |EffectScriptEntry { ident, url }| {
260            let ident_string = ident.to_string();
261
262            (
263                Ident::new(&format!("use_{}", ident_string), ident.span()),
264                Ident::new(&format!("{}ScriptHandle", ident_string), Span::call_site()),
265                url,
266                Ident::new(
267                    &format!("{}_script_loader", ident_string),
268                    Span::call_site(),
269                ),
270                Ident::new(
271                    &format!("{}_script_handle", ident_string),
272                    Span::call_site(),
273                ),
274            )
275        },
276    ));
277
278    let (resource_names, resource_name_spans): (Vec<_>, Vec<_>) =
279        idents.iter().map(|i| (i.to_string(), i.span())).unzip();
280
281    let handle_idents = resource_names
282        .iter()
283        .map(|name| Ident::new(&format!("{}LinkGroupStatusHandle", name), Span::call_site()));
284
285    let library_hooks = izip!(
286        resource_names.iter(),
287        resource_name_spans,
288        link_groups.iter(),
289        handle_idents.clone()
290    )
291    .map(|(resource_name, span, links, handle_ident)| {
292        let ident = format!("use_{}", resource_name);
293        let ident = Ident::new(&ident, span);
294
295        let links = links.iter().map(|LibraryUrl { link_type, url }| {
296            let r#type = match link_type {
297                LinkType::Css => quote! {Css},
298                LinkType::Js => quote! {Js},
299            };
300
301            quote! {
302                yew_interop::Link {
303                    r#type: ::yew_interop::LinkType::#r#type,
304                    src: ::std::borrow::Cow::from(#url),
305                }
306            }
307        });
308        let handle_ident_one = handle_ident.clone();
309        let handle_ident_two = handle_ident.clone();
310        let handle_ident_three = handle_ident.clone();
311
312        quote! {
313
314            /// Request the library to be loaded.
315            /// Returns None when the library is first requested or
316            /// is requested elsewhere but not loaded yet.
317            /// The component will get notified when the library is ready.
318            pub fn #ident() -> bool{
319                let handle = ::yew::use_context::<#handle_ident_one>().unwrap();
320                match handle {
321                    #handle_ident_two::NotRequested(disp) => {
322                        disp.dispatch(::yew_interop::LinkGroupStatusAction::PleaseStart(vec![
323                            #(
324                                #links,
325                            )*
326                        ]));
327                        false
328                    }
329                    #handle_ident_three::Started => false,
330                    #handle_ident::Completed => true
331                }
332            }
333        }
334    });
335
336    #[cfg(feature = "script")]
337    let script_hooks = {
338        let script_handle_enums_one = script_handle_enums.iter();
339        let script_handle_enums_two = script_handle_enums_one.clone();
340        let script_handle_enums_three = script_handle_enums_one.clone();
341        let script_handle_enums_four = script_handle_enums_one.clone();
342        quote! {
343
344            #(
345                /// Request the script to be loaded.
346                /// Returns None when the script is first requested or is requested elsewhere but not loaded yet.
347                /// The component will get notified when the script is ready.
348                pub fn #script_hooks() -> Option<::yew_interop::script::Script> {
349                    let handle = ::yew::use_context::<#script_handle_enums_four>().unwrap();
350                    match handle {
351                        #script_handle_enums_one::NotRequested(disp) => {
352                            disp.dispatch(::yew_interop::script::ScriptLoaderAction::Start);
353                            ::yew_interop::script::wasm_bindgen_futures::spawn_local(async move {
354                                let script = ::yew_interop::script::fetch_script(#script_urls.into()).await;
355                                disp.dispatch(::yew_interop::script::ScriptLoaderAction::Finish(
356                                    ::std::rc::Rc::new(script),
357                                ));
358                            });
359                            None
360                        }
361                        #script_handle_enums_two::Started => None,
362                        #script_handle_enums_three::Completed(s) => Some(s),
363                    }
364                }
365            )*
366
367
368        }
369    };
370    #[cfg(not(feature = "script"))]
371    let script_hooks = quote! {};
372
373    #[cfg(feature = "script")]
374    let script_handle_enums_ts = {
375        let script_handle_enums = script_handle_enums.iter();
376        quote! {
377
378            #(
379                #[derive(Clone, PartialEq)]
380                enum #script_handle_enums {
381                    NotRequested(::yew::UseReducerDispatcher<::yew_interop::script::ScriptLoader>),
382                    Started,
383                    Completed(::yew_interop::script::Script),
384                }
385            )*
386
387        }
388    };
389    #[cfg(not(feature = "script"))]
390    let script_handle_enums_ts = quote! {};
391
392    #[cfg(feature = "script")]
393    let script_loaders_and_handles = {
394        let script_loaders_one = script_loaders.iter();
395        let script_loaders_two = script_loaders_one.clone();
396        let script_handle_enums = script_handle_enums.iter();
397        let script_handle_enums_one = script_handle_enums.clone();
398        let script_handle_enums_two = script_handle_enums.clone();
399        let script_handles = script_handles.iter();
400        quote! {
401
402            #(
403
404                let #script_loaders =
405                    ::yew::use_reducer(|| ::yew_interop::script::ScriptLoader::NotRequested);
406                let #script_handles = match &*#script_loaders_one {
407                    yew_interop::script::ScriptLoader::NotRequested => {
408                        #script_handle_enums_two::NotRequested(#script_loaders_two.dispatcher())
409                    }
410                    yew_interop::script::ScriptLoader::Started => #script_handle_enums::Started,
411                    yew_interop::script::ScriptLoader::Completed(s) => {
412                        #script_handle_enums_one::Completed(s.clone())
413                    }
414                };
415
416            )*
417
418
419        }
420    };
421    #[cfg(not(feature = "script"))]
422    let script_loaders_and_handles = quote! {};
423
424    let handle_idents_one = handle_idents.clone();
425    let handle_idents_two = handle_idents.clone();
426    let handle_idents_three = handle_idents.clone();
427    let handle_idents_four = handle_idents.clone();
428    let handle_idents_five = handle_idents.clone().rev();
429
430    let handle_enums = handle_idents.clone().map(|handle_ident| {
431        quote! {
432            #[derive(Clone, PartialEq)]
433            enum #handle_ident {
434                NotRequested(::yew::UseReducerDispatcher<::yew_interop::LinkGroupStatus>),
435                Started,
436                Completed,
437            }
438        }
439    });
440
441    let remaining_idents = resource_names
442        .iter()
443        .map(|name| Ident::new(&format!("{}_REMAINING", name), Span::call_site()));
444    let remaining_idents_one = remaining_idents.clone();
445    let remaining_count = link_groups
446        .iter()
447        .map(|link_group| LitInt::new(&link_group.len().to_string(), Span::call_site()));
448
449    let reducer_idents = resource_names
450        .iter()
451        .map(|name| Ident::new(&format!("{}_link_group_status", name), Span::call_site()));
452
453    let handle_tmp_idents = resource_names
454        .iter()
455        .map(|name| Ident::new(&format!("{}_link_handle", name), Span::call_site()));
456
457    let handle_tmp_idents_one = handle_tmp_idents.clone();
458
459    let link_element_tmp_idents = resource_names
460        .iter()
461        .map(|name| Ident::new(&format!("{}_links", name), Span::call_site()));
462
463    let link_element_tmp_idents_one = link_element_tmp_idents.clone();
464    let reducer_idents_one = reducer_idents.clone();
465    let reducer_idents_two = reducer_idents.clone();
466    let reducer_idents_three = reducer_idents.clone();
467
468    let expanded = {
469        #[cfg(feature = "script")]
470        let script_context_opening_tags = {
471            let script_handle_enums = script_handle_enums.iter();
472
473            quote! {
474                #(
475                    <::yew::ContextProvider<#script_handle_enums> context={#script_handles}>
476                )*
477            }
478        };
479        #[cfg(not(feature = "script"))]
480        let script_context_opening_tags = quote! {};
481
482        #[cfg(feature = "script")]
483        let script_context_closing_tags = {
484            let script_handle_enums = script_handle_enums.into_iter().rev();
485            quote! {
486                    #(
487                        </::yew::ContextProvider<#script_handle_enums>>
488                    )*
489            }
490        };
491        #[cfg(not(feature = "script"))]
492        let script_context_closing_tags = quote! {};
493
494        quote! {
495
496            #(
497                #library_hooks
498            )*
499
500            #script_hooks
501
502            #script_handle_enums_ts
503
504            #(
505                #handle_enums
506            )*
507
508            #[derive(::yew::Properties, PartialEq)]
509            pub struct ResourceProviderProps {
510                pub children: ::yew::Children,
511            }
512
513            #[::yew::function_component(ResourceProvider)]
514            pub fn resource_provider(props: &ResourceProviderProps) -> Html {
515                #(
516                    let #reducer_idents = ::yew::use_reducer(::yew_interop::LinkGroupStatus::default);
517                )*
518
519                thread_local!{
520                    #(
521                        static #remaining_idents: ::std::cell::RefCell<u8> = ::std::cell::RefCell::new(#remaining_count);
522                    )*
523                }
524
525                let make_links_with_onload = |links: Vec<::yew_interop::Link>, onload: Option<::yew::Callback<::yew::Event>>|{
526                    ::yew::html!{
527                        <>
528                            {for
529                                links.into_iter().map(|link| {
530
531                                    let src: ::yew::virtual_dom::AttrValue = link.src.clone().into();
532                                    match link.r#type {
533                                        ::yew_interop::LinkType::Js => ::yew::html! {
534                                            <script {src} type="text/javascript" onload={onload.clone()}/>
535                                        },
536                                        ::yew_interop::LinkType::Css => ::yew::html! {
537                                            <link rel="stylesheet" type="text/css" href={src} onload={onload.clone()}/>
538                                        }
539                                    }
540                                    }
541                                )
542                            }
543                        </>
544                    }
545                };
546
547                let get_links =
548                    |links: &Vec<::yew_interop::Link>,
549                     link_group_status: &::yew::UseReducerHandle<::yew_interop::LinkGroupStatus>,
550                     count: &'static ::std::thread::LocalKey<::std::cell::RefCell<u8>>| {
551                        let dispatcher = link_group_status.dispatcher();
552                        let onload = move |_| {
553                            count.with(|r| {
554                                let current = *r.borrow();
555                                *r.borrow_mut() = current - 1;
556                                if current > 1 {
557                                    *r.borrow_mut() = current - 1
558                                } else {
559                                    dispatcher.dispatch(::yew_interop::LinkGroupStatusAction::Completed)
560                                }
561                            })
562                        };
563                        make_links_with_onload(links.clone(), Some(::yew::Callback::from(onload)))
564                    };
565
566
567                #(
568                    let (#handle_tmp_idents, #link_element_tmp_idents) = match &*#reducer_idents_one {
569                        ::yew_interop::LinkGroupStatus::Started {links} => (
570                            #handle_idents_one::Started,
571                            get_links(links, &#reducer_idents_two, &#remaining_idents_one)
572                        ),
573                        ::yew_interop::LinkGroupStatus::Completed{links} => {
574                            (#handle_idents_two::Completed,
575                                                              make_links_with_onload(links.clone(), None))},
576                        ::yew_interop::LinkGroupStatus::NotRequested => (#handle_idents_three::NotRequested(#reducer_idents_three.dispatcher()), ::yew::html!{})
577                    };
578                )*
579
580                #script_loaders_and_handles
581
582                ::yew::html! {
583                    <>
584                        #(
585                            <::yew::ContextProvider<#handle_idents_four> context={#handle_tmp_idents_one}>
586                        )*
587
588                        #script_context_opening_tags
589                            {for props.children.iter()}
590                        #script_context_closing_tags
591
592                        #(
593                            </::yew::ContextProvider<#handle_idents_five>>
594                        )*
595                        // temptation: use portal and render this into <head/>
596                        //  it doesn't work because yew attaches listeners to body.
597                        //  The onload listeners would never fire.
598                        //  it's a current limitation of portals
599                        #(
600                            {#link_element_tmp_idents_one}
601                        )*
602                    </>
603                }
604            }
605
606
607        }
608    };
609
610    expanded.into()
611}
612
613trait ExprExt {
614    fn into_lit_str(self) -> SynResult<Either<LitStr, Expr>>;
615}
616
617impl ExprExt for syn::Expr {
618    fn into_lit_str(self) -> SynResult<Either<LitStr, Self>> {
619        match self {
620            syn::Expr::Lit(ExprLit { lit, .. }) => match lit {
621                Lit::Str(lit_str) => Ok(Either::Left(lit_str)),
622                _ => Err(SynError::new(lit.span(), "expecting a string literal")),
623            },
624            other => Ok(Either::Right(other)),
625        }
626    }
627}