diff --git a/Cargo.toml b/Cargo.toml index 9148e68..6072885 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,20 @@ [package] name = "dioxus_markdown" -version = "0.3.0-alpha.1" +version = "0.4.0-alpha.1" edition = "2021" [dependencies] -dioxus = "0.6.0-alpha.1" -pulldown-cmark = "0.9.3" +dioxus = "0.6.0" +markdown = "1.0.0" -[dev-dependencies] -dioxus = { version = "0.6.0-alpha.1", features = ["desktop"] } +[profile] + +[profile.wasm-dev] +inherits = "dev" +opt-level = 1 + +[profile.server-dev] +inherits = "dev" + +[profile.android-dev] +inherits = "dev" diff --git a/README.md b/README.md index 2cefc07..22f9bb7 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,3 @@ rsx! { } } ``` - -## Features - -- Convert strings to vnodes on the fly with `tvnodes` - -## Warning: - -- Currently, this crate uses the pulldown-cmark to html converter with no actual intermediate step to Dioxus VNodes. -- Content is set with `dangerous_inner_html` with no actual translation to VNodes occurring. -- Macros are not currently implemented. - -For most use cases, this approach will work fine. However, if you feel brave enough to add a true Markdown to VNode converter, we'd happily coach and assist the implementation/pull request. diff --git a/examples/bulma.rs b/examples/bulma.rs index 5aab30b..ca88fd5 100644 --- a/examples/bulma.rs +++ b/examples/bulma.rs @@ -12,6 +12,6 @@ fn app() -> Element { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" } - div { class: "container is-fluid", Markdown { class: class, content: include_str!("../README.md") } } + div { class: "container is-fluid", Markdown { class: class, content: include_str!("./example.md") } } } } diff --git a/examples/example.md b/examples/example.md new file mode 100644 index 0000000..b39f720 --- /dev/null +++ b/examples/example.md @@ -0,0 +1,71 @@ +# Markdown Features Demo + +## Paragraphs + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos. + +## Text Formatting + +- **Bold text** is created with double asterisks. +- *Italic text* is created with single asterisks +- ***Bold and italic*** text uses three asterisks +- ~~Strikethrough~~ text uses two tildes + +## Lists + +### Ordered Lists +1. First item +2. Second item +3. Third item + +### Unordered Lists +- Item one +- Item two + - Nested item + - Another nested item +- Item three + +## Links and Images + +[Visit GitHub](https://github.com) + +![Mountains](https://images.unsplash.com/photo-1752035680973-79d3836f317a?q=80&w=870&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) + +https://github.com + +tel:12345678 + +## Code + +Inline `code` uses backticks + +```python +def hello_world(): + print("Hello, Markdown!") +``` + +## Blockquotes + +> This is a blockquote +> +> It can span multiple lines + +## Tables + +| Header 1 | Header 2 | Header 3 | +|----------|----------|----------| +| Cell 1 | Cell 2 | Cell 3 | +| Cell 4 | Cell 5 | Cell 6 | + +## Horizontal Rule + +--- + +## Task Lists + +- [x] Completed task +- [ ] Incomplete task diff --git a/src/lib.rs b/src/lib.rs index 823df32..a560548 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ #![allow(non_snake_case)] use dioxus::prelude::*; -use pulldown_cmark::Parser; +use markdown::{self, mdast, Constructs, ParseOptions}; #[derive(Props, Clone, PartialEq)] pub struct MarkdownProps { @@ -9,23 +9,373 @@ pub struct MarkdownProps { id: Signal, #[props(default)] class: Signal, - content: ReadOnlySignal, + #[props(default)] + error_view: Option } /// Render some text as markdown. pub fn Markdown(props: MarkdownProps) -> Element { let content = &*props.content.read(); - let parser = Parser::new(content); - - let mut html_buf = String::new(); - pulldown_cmark::html::push_html(&mut html_buf, parser); + // TODO: Ideally we should allow parse options to be customizable externally + let parse_options = ParseOptions { + constructs: Constructs { + attention: true, + autolink: true, + block_quote: true, + character_escape: true, + character_reference: true, + code_indented: true, + code_fenced: true, + code_text: true, + definition: true, + frontmatter: false, + gfm_autolink_literal: true, + gfm_label_start_footnote: false, + gfm_footnote_definition: false, + gfm_strikethrough: true, + gfm_table: true, + gfm_task_list_item: true, + hard_break_escape: true, + hard_break_trailing: true, + heading_atx: true, + heading_setext: true, + html_flow: false, + html_text: false, + label_start_image: true, + label_start_link: true, + label_end: true, + list_item: true, + math_flow: true, + math_text: true, + mdx_esm: false, + mdx_expression_flow: false, + mdx_expression_text: false, + mdx_jsx_flow: false, + mdx_jsx_text: false, + thematic_break: true, + }, + gfm_strikethrough_single_tilde: false, + math_text_single_dollar: false, + mdx_expression_parse: None, + mdx_esm_parse: None, + }; + let mdast = match markdown::to_mdast(content, &parse_options) { + Ok(mdast) => mdast, + Err(err) => return props.error_view.unwrap_or(rsx! { "Error parsing markdown: {err}" }) + }; rsx! { div { - id: "{&*props.id.read()}", - class: "{&*props.class.read()}", - dangerous_inner_html: "{html_buf}" + id: "{props.id}", + class: "{props.class}", + ASTNodeView { node: mdast } } } } + +#[derive(Props, Clone, PartialEq)] +pub struct ASTNodeViewProps { + node: mdast::Node, +} + +pub fn ASTNodeView(props: ASTNodeViewProps) -> Element { + // TODO: Allow customization for views that want to customize behavior for each markdown node + match props.node { + mdast::Node::Root(root) => { + rsx! { + article { + for child in root.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::Blockquote(blockquote) => { + rsx! { + blockquote { + for child in blockquote.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::FootnoteDefinition(footnote_definition) => { + rsx! { + div { + for child in footnote_definition.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::MdxJsxFlowElement(mdx_jsx_flow_element) => { + rsx! { + div { + for child in mdx_jsx_flow_element.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::List(list) => { + if list.ordered { + rsx! { + ol { + start: if list.start.unwrap_or(1) != 1 { "{list.start.unwrap_or(1)}" } else { "" }, + for child in list.children { + ASTNodeView { node: child } + } + } + } + } else { + rsx! { + ul { + for child in list.children { + ASTNodeView { node: child } + } + } + } + } + }, + mdast::Node::MdxjsEsm(mdxjs_esm) => { + rsx! { + pre { + code { + "{mdxjs_esm.value}" + } + } + } + }, + mdast::Node::Toml(toml) => { + rsx! { + pre { + code { + "{toml.value}" + } + } + } + }, + mdast::Node::Yaml(yaml) => { + rsx! { + pre { + code { + "{yaml.value}" + } + } + } + }, + mdast::Node::Break(_) => { + rsx! { br {} } + }, + mdast::Node::InlineCode(inline_code) => { + rsx! { + code { + "{inline_code.value}" + } + } + }, + mdast::Node::InlineMath(inline_math) => { + rsx! { + span { + "{inline_math.value}" + } + } + }, + mdast::Node::Delete(delete) => { + rsx! { + del { + for child in delete.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::Emphasis(emphasis) => { + rsx! { + em { + for child in emphasis.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::MdxTextExpression(mdx_text_expression) => { + rsx! { + span { + "{mdx_text_expression.value}" + } + } + }, + mdast::Node::FootnoteReference(footnote_reference) => { + rsx! { + sup { + a { + href: "#footnote-{footnote_reference.identifier}", + "[{footnote_reference.identifier}]" + } + } + } + }, + mdast::Node::Html(html) => { + rsx! { + div { + dangerous_inner_html: "{html.value}" + } + } + }, + mdast::Node::Image(image) => { + rsx! { + img { + src: "{image.url}", + alt: "{image.alt}", + title: image.title.unwrap_or_default() + } + } + }, + mdast::Node::ImageReference(image_reference) => { + rsx! { + span { + "[{image_reference.identifier}]" + } + } + }, + mdast::Node::MdxJsxTextElement(mdx_jsx_text_element) => { + rsx! { + span { + for child in mdx_jsx_text_element.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::Link(link) => { + rsx! { + a { + href: "{link.url}", + title: link.title.unwrap_or_default(), + for child in link.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::LinkReference(link_reference) => { + rsx! { + span { + "[{link_reference.identifier}]" + } + } + }, + mdast::Node::Strong(strong) => { + rsx! { + strong { + for child in strong.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::Text(text) => { + rsx! { + "{text.value}" + } + }, + mdast::Node::Code(code) => { + rsx! { + pre { + class: if let Some(lang) = &code.lang { "language-{lang}" } else { "" }, + code { + "{code.value}" + } + } + } + }, + mdast::Node::Math(math) => { + rsx! { + div { + "{math.value}" + } + } + }, + mdast::Node::MdxFlowExpression(mdx_flow_expression) => { + rsx! { + div { + "{mdx_flow_expression.value}" + } + } + }, + mdast::Node::Heading(heading) => { + match heading.depth { + 1 => rsx! { h1 { for child in heading.children { ASTNodeView { node: child } } } }, + 2 => rsx! { h2 { for child in heading.children { ASTNodeView { node: child } } } }, + 3 => rsx! { h3 { for child in heading.children { ASTNodeView { node: child } } } }, + 4 => rsx! { h4 { for child in heading.children { ASTNodeView { node: child } } } }, + 5 => rsx! { h5 { for child in heading.children { ASTNodeView { node: child } } } }, + _ => rsx! { h6 { for child in heading.children { ASTNodeView { node: child } } } }, + } + }, + mdast::Node::Table(table) => { + rsx! { + table { + for child in table.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::ThematicBreak(_) => { + rsx! { + hr {} + } + }, + mdast::Node::TableRow(table_row) => { + rsx! { + tr { + for child in table_row.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::TableCell(table_cell) => { + rsx! { + td { + for child in table_cell.children { + ASTNodeView { node: child } + } + } + } + }, + mdast::Node::ListItem(list_item) => { + rsx! { + li { + if let Some(checked) = list_item.checked { + input { + r#type: "checkbox", + checked: checked, + disabled: true, + } + } + span { + for child in list_item.children { + ASTNodeView { node: child } + } + } + } + } + }, + mdast::Node::Definition(_) => { + todo!() + }, + mdast::Node::Paragraph(paragraph) => { + rsx! { + p { + for child in paragraph.children { + ASTNodeView { node: child } + } + } + } + }, + } +}