diff --git a/packages/yew-macro/src/lib.rs b/packages/yew-macro/src/lib.rs index f7950538ffb..85cb73bf7c2 100644 --- a/packages/yew-macro/src/lib.rs +++ b/packages/yew-macro/src/lib.rs @@ -51,6 +51,7 @@ mod derive_props; mod function_component; mod hook; mod html_tree; +mod mdx; mod props; mod stringify; mod use_prepared_state; @@ -186,3 +187,19 @@ pub fn use_transitive_state_without_closure(input: TokenStream) -> TokenStream { let transitive_state = parse_macro_input!(input as TransitiveState); transitive_state.to_token_stream_without_closure().into() } + +#[proc_macro] +pub fn mdx(input: TokenStream) -> TokenStream { + let mdx_tokens = mdx::mdx(input); + html(mdx_tokens) +} + +#[proc_macro] +pub fn mdx_style(input: TokenStream) -> TokenStream { + mdx::mdx_style(input) +} + +#[proc_macro] +pub fn include_mdx(input: TokenStream) -> TokenStream { + html(mdx::include_mdx(input)) +} diff --git a/packages/yew-macro/src/mdx/cmark.rs b/packages/yew-macro/src/mdx/cmark.rs new file mode 100644 index 00000000000..0049fcf66ee --- /dev/null +++ b/packages/yew-macro/src/mdx/cmark.rs @@ -0,0 +1,173 @@ +use proc_macro::TokenStream; +use pulldown_cmark::{Event, Options, Parser, Tag}; +use quote::quote; + +use super::GLOBAL_STYLE; + +// styling idea: +// caller passes in mapping of tag to yew component name +// caller can implement component however they want, it can take children +// instead of rendering

we render +// problem: has to be specified at every call-site, and it's verbose because it has to be the input +// to the proc macro +// +// take 2: use yew dynamic tags to lookup in a named or global style array, +// including fallbacks to defaults. how to control whether style is applied at +// all? could have 2 different mdx macros, mdx/mdxs (global style) / mdxss +// (user-specified name of style map) +// Disadvantage: yew component name validation done at runtime +// instead of mapping to component name, could just map to Fn(children)-> Html +// dealbreaker?: do dynamic tags work with components anyways?? +// +// take2.5: just dont use dynamic tag?? -- but then we need the map specified at compile-time +// +// take 3: separate mdx_style! macro to define styles + +// map static tag to dynamic tag, falling back to the given tag +#[derive(PartialEq)] +enum Side { + Start, + End, +} + +fn dyn_tag_name_opt(tag: &str) -> Option { + GLOBAL_STYLE.lock().unwrap().get(tag).map(Into::into) +} +fn dyn_tag_name(tag: &str) -> String { + dyn_tag_name_opt(tag).unwrap_or(tag.into()) +} + +fn dyn_tag(tag: &str, side: Side) -> TokenStream { + let tag = dyn_tag_name_opt(tag).unwrap_or(tag.into()); + (match side { + Side::Start => "<", + Side::End => "") + .parse() + .unwrap() +} + +fn dyn_tag_opt(tag: &str, side: Side) -> Option { + GLOBAL_STYLE.lock().unwrap().get(tag.into()).map(|tag| { + (match side { + Side::Start => "<", + Side::End => "") + .parse() + .unwrap() + }) +} + +pub fn parse_commonmark(input: &str) -> TokenStream { + let parser = Parser::new_ext(input, Options::all()); + + let mut toks = TokenStream::new(); + toks.extend::("<>".parse().unwrap()); + + parser.for_each(|evt| { + // dbg!(&evt); + let new_toks: TokenStream = match evt { + Event::Start(tag) => match tag { + Tag::Paragraph => dyn_tag("p", Side::Start), + Tag::Heading(lvl, ..) => dyn_tag(&lvl.to_string(), Side::Start), + Tag::BlockQuote => dyn_tag("blockquote", Side::Start), + Tag::CodeBlock(kind) => match kind { + pulldown_cmark::CodeBlockKind::Indented => FromIterator::from_iter( + [ + dyn_tag("pre", Side::Start), + "".parse::().unwrap(), + ] + .into_iter(), + ), + pulldown_cmark::CodeBlockKind::Fenced(lang) => { + let tags = FromIterator::from_iter( + [ + dyn_tag("pre", Side::Start), + format!("", lang) + .parse::() + .unwrap(), + ] + .into_iter(), + ); + + tags + } + }, + Tag::List(None) => dyn_tag("ul", Side::Start), + Tag::List(Some(0)) => dyn_tag("ol", Side::Start), + Tag::List(Some(0..=u64::MAX)) => dyn_tag("ol", Side::Start), + Tag::Item => dyn_tag("li", Side::Start), + Tag::FootnoteDefinition(_) => todo!(), + Tag::Table(_) => dyn_tag("table", Side::Start), + Tag::TableHead => dyn_tag("thead", Side::Start), + Tag::TableRow => dyn_tag("tr", Side::Start), + Tag::TableCell => dyn_tag("td", Side::Start), + Tag::Emphasis => dyn_tag("em", Side::Start), + Tag::Strong => dyn_tag("strong", Side::Start), + Tag::Strikethrough => dyn_tag("s", Side::Start), + Tag::Link(_type, url, title) => { + format!("<{} href=\"{}\">{}", dyn_tag_name("a").to_string(), url, title) + .parse() + .unwrap() + } + Tag::Image(_type, url, title) => { + let tag = dyn_tag_name("url"); + format!(r#"<{tag} src="{url}" title="{title}"/>"#) + .parse() + .unwrap() + } + }, + Event::End(tag) => match tag { + Tag::Paragraph => dyn_tag("p", Side::End), + Tag::Heading(lvl, ..) => dyn_tag(&lvl.to_string(), Side::End), + Tag::BlockQuote => dyn_tag("blockquote", Side::End), + Tag::CodeBlock(_) => { + FromIterator::from_iter(["".parse().unwrap(), dyn_tag("pre", Side::End)]) + } + Tag::List(None) => dyn_tag("ul", Side::End), + Tag::List(Some(0)) => dyn_tag("ol", Side::End), + Tag::List(Some(0..=u64::MAX)) => dyn_tag("ol", Side::End), + Tag::Item => dyn_tag("li", Side::End), + Tag::FootnoteDefinition(_) => todo!(), + Tag::Table(_) => dyn_tag("table", Side::End), + Tag::TableHead => dyn_tag("thead", Side::End), + Tag::TableRow => dyn_tag("tr", Side::End), + Tag::TableCell => dyn_tag("td", Side::End), + Tag::Emphasis => dyn_tag("em", Side::End), + Tag::Strong => dyn_tag("strong", Side::End), + Tag::Strikethrough => dyn_tag("s", Side::End), + Tag::Link(_type, _url, _title) => dyn_tag("a", Side::End), + Tag::Image(_type, _url, _title) => "".parse().unwrap(), + }, + Event::Text(txt) => format!("{{r###\"{}\"###}}", txt).parse().unwrap(), + Event::Code(code) => { + let tag = dyn_tag_name("code"); + format!("<{tag}>{{r###\"{}\"###}}", code) + .parse() + .unwrap() + } + Event::Rule => { + let tag = dyn_tag_name("hr"); + format!("<{tag} />").parse().unwrap() + } + Event::SoftBreak => "{{\" \"}}".parse().unwrap(), + Event::HardBreak => { + let tag = dyn_tag_name("br"); + format!("<{tag} />").parse().unwrap() + }, + Event::Html(html) => html.parse().unwrap(), + _ => quote! {}.into(), + }; + toks.extend(new_toks); + }); + + toks.extend::("".parse().unwrap()); + + toks +} diff --git a/packages/yew-macro/src/mdx/mod.rs b/packages/yew-macro/src/mdx/mod.rs new file mode 100644 index 00000000000..8d4ca9da074 --- /dev/null +++ b/packages/yew-macro/src/mdx/mod.rs @@ -0,0 +1,75 @@ +mod cmark; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use proc_macro::{TokenStream, TokenTree}; + +use self::cmark::parse_commonmark; + +lazy_static::lazy_static! { +static ref GLOBAL_STYLE : Arc>> = { + Default::default() +}; +} + +pub fn mdx_style(input: TokenStream) -> TokenStream { + let input = input.into_iter().collect::>(); + for chunk in input.chunks(4) { + let (from, to) = (chunk.get(0), chunk.get(2)); + match from.zip(to) { + Some((TokenTree::Ident(from), TokenTree::Ident(to))) => { + GLOBAL_STYLE + .lock() + .unwrap() + .insert(from.to_string(), to.to_string()); + } + _ => {} + } + } + // GLOBAL_STYLE + // .lock() + // .unwrap() + // .insert("h3".into(), "MyHeading3".into()); + quote::quote! {}.into() +} + +pub fn mdx(input: TokenStream) -> TokenStream { + let parsed = input + .into_iter() + .map(|token| match token { + lit @ TokenTree::Literal(_) => { + let mdx_str = lit.to_string(); + let mdx_str = mdx_str + .strip_prefix("r#\"") + .unwrap() + .strip_suffix("\"#") + .unwrap(); + parse_commonmark(&mdx_str) + } + _ => panic!("mdx! expected literal"), + }) + .collect::(); + + parsed +} + +pub fn include_mdx(input: TokenStream) -> TokenStream { + let file_path: std::path::PathBuf = input + .to_string() + .trim_start_matches('"') + .trim_end_matches('"') + .parse() + .unwrap(); + + let full_path = std::env::var("CARGO_MANIFEST_DIR") + .unwrap() + .parse::() + .unwrap() + .join(file_path); + let contents = std::fs::read_to_string(full_path) + .unwrap() + .replace("\r", ""); + + parse_commonmark(&contents) +} diff --git a/packages/yew-macro/tests/mdx_macro_test.rs b/packages/yew-macro/tests/mdx_macro_test.rs new file mode 100644 index 00000000000..2d1a23e33f7 --- /dev/null +++ b/packages/yew-macro/tests/mdx_macro_test.rs @@ -0,0 +1,221 @@ +use yew::{Children, Html}; +use yew_macro::{function_component, html, mdx, mdx_style, Properties}; + +// h3 used for testing styling +#[cfg(test)] +mdx_style!(h3: MyHeading3,); + +#[derive(Properties, PartialEq)] +struct MyPProps { + children: Children, +} + +#[function_component] +fn MyP(props: &MyPProps) -> Html { + html! { +

{props.children.clone()}

+ } +} + +#[test] +fn text() { + assert_eq!( + mdx! { + r#"hi"# + }, + html! {<>

{"hi"}

} + ); +} + +#[test] +fn h1() { + dbg_eq( + mdx! { + r#"# hi"# + }, + html! { + <>

{"hi"}

+ }, + ); +} + +#[test] +fn a() { + dbg_eq( + mdx! { + r#"[this is a link](google.com)"# + }, + html! { + <>

{"this is a link"}

+ }, + ) +} + +#[test] +fn nested() { + dbg_eq( + mdx! { + r#"# Wow a [link](google.com) in a title"# + }, + html! { + <>

{"Wow a "}{"link"}{" in a title"}

+ }, + ) +} + +#[test] +fn multiple() { + dbg_eq( + mdx! { + r#"Some text [link](google.com)"# + }, + html! { + <> +

+ {"Some text "} + {"link"} +

+ + }, + ) +} + +#[test] +fn multiline_text() { + dbg_eq( + mdx! { r#"this is some text that + spans multiple lines"# + }, + html! {<>

{"this is some text that"}{" "}{"spans multiple lines"}

}, + ) +} + +#[test] +fn multiline_link() { + dbg_eq( + mdx! { r#"[this is a + multiline link wow](google.com)"#}, + html! { + <> +

+ {"this is a"} {" "} {"multiline link wow"} +

+ + }, + ) +} + +#[test] +fn basic_code() { + dbg_eq( + mdx! {r#"here is some `inline code` ooo"#}, + html! { + <> +

+ {"here is some "}{"inline code"}{" ooo"} +

+ + }, + ); + dbg_eq( + mdx! {r#"# header `inline code` ooo"#}, + html! { + <> +

+ {"header "}{"inline code"}{" ooo"} +

+ + }, + ); + dbg_eq( + mdx! {r#"# header [link `inline code`](google.com) ooo"#}, + html! { + <> +

+ {"header "}{"link "}{"inline code"}{" ooo"} +

+ + }, + ); +} + +#[test] +fn list() { + dbg_eq( + mdx! {r#" +- one +- two +- three +"#}, + html! { + <> + + + + }, + ) +} + +#[test] +fn component() { + dbg_eq( + mdx! {r#" +# +"#}, + html! { + <> +

+ +

+ + }, + ); +} + +#[test] +fn quotes_escaped() { + // NOTE: if quotes aren't correctly esacped, this will panic as invalid + // syntax because of + // https://doc.rust-lang.org/edition-guide/rust-2021/reserving-syntax.html#summary + mdx!(r#"i "like"quotes"" """"#); +} + +fn dbg_eq(a: T, b: T) { + assert_eq!(format!("{a:?}"), format!("{b:?}")); +} + +#[function_component] +fn TestComponent() -> Html { + html! { +
+ } +} + +#[derive(PartialEq, Properties)] +struct MyHeading3Props { + pub children: Children, +} +#[function_component] +fn MyHeading3(c: &MyHeading3Props) -> Html { + html! { +

+ + {c.children.clone()} + +

+ } +} + +#[test] +fn style_h3() { + dbg_eq( + mdx! {r#"### 123"#}, + html! { + <>{"123"} + }, + ) +}