use std::fmt::Write;
use crate::Options;
use super::{Segment, segment};
pub struct MdxOutput<'a> {
pub body: String,
pub esm: Vec<&'a str>,
pub front_matter: Option<&'a str>,
}
impl MdxOutput<'_> {
pub fn to_component(&self, name: &str) -> String {
let mut out = String::with_capacity(self.body.len() + self.esm.len() * 40 + 80);
for esm in &self.esm {
out.push_str(esm.trim_end());
out.push('\n');
}
if !self.esm.is_empty() {
out.push('\n');
}
let _ = writeln!(out, "export function {name}() {{");
out.push_str(" return (\n <>\n");
let body = self.body.trim();
if !body.is_empty() {
for line in body.lines() {
if line.is_empty() {
out.push('\n');
} else {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
}
}
out.push_str(" </>\n );\n}\n");
out
}
}
pub fn render(input: &str) -> MdxOutput<'_> {
render_with_options(input, &mdx_default_options())
}
pub fn render_with_options<'a>(input: &'a str, options: &Options) -> MdxOutput<'a> {
let segments = segment(input);
let mut body = String::with_capacity(input.len());
let mut esm: Vec<&'a str> = Vec::new();
let mut front_matter: Option<&'a str> = None;
for seg in &segments {
match seg {
Segment::Esm(s) => {
esm.push(s);
}
Segment::Markdown(s) => {
let result = crate::parse_with_options(s, options);
body.push_str(&result.html);
if front_matter.is_none() {
front_matter = result.front_matter;
}
}
Segment::JsxBlockOpen(s)
| Segment::JsxBlockClose(s)
| Segment::JsxBlockSelfClose(s)
| Segment::Expression(s) => {
body.push_str(s.trim());
body.push('\n');
}
}
}
MdxOutput {
body,
esm,
front_matter,
}
}
fn mdx_default_options() -> Options {
Options {
allow_html: true,
disallowed_raw_html: false,
front_matter: true,
heading_ids: true,
..Options::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_markdown() {
let out = render("# Hello\n\nWorld\n");
assert!(out.body.contains("<h1"));
assert!(out.body.contains("Hello"));
assert!(out.body.contains("<p>World</p>"));
assert!(out.esm.is_empty());
assert!(out.front_matter.is_none());
}
#[test]
fn only_esm() {
let out = render("import A from 'a'\nexport const x = 1\n");
assert_eq!(out.esm.len(), 2);
assert!(out.esm[0].contains("import A"));
assert!(out.esm[1].contains("export const"));
assert!(!out.body.contains('<'));
}
#[test]
fn mixed_esm_markdown_jsx_expression() {
let input = "\
import { Card } from './card'
export const meta = { title: 'Test' }
# Title
Paragraph.
<Card title=\"hello\">
## Inside
</Card>
{new Date().getFullYear()}
";
let out = render(input);
assert_eq!(out.esm.len(), 2);
assert!(out.body.contains("<h1"));
assert!(out.body.contains("<p>Paragraph.</p>"));
assert!(out.body.contains("<Card title=\"hello\">"));
assert!(out.body.contains("</Card>"));
assert!(out.body.contains("<h2"));
assert!(out.body.contains("Inside"));
assert!(out.body.contains("new Date().getFullYear()"));
}
#[test]
fn front_matter_extraction() {
let input = "---\ntitle: Hello\n---\n\n# Heading\n";
let out = render(input);
assert_eq!(out.front_matter, Some("title: Hello\n"));
assert!(out.body.contains("<h1"));
}
#[test]
fn inline_html_passthrough() {
let input = "Text with <sl-button>Click</sl-button> here.\n";
let out = render(input);
assert!(out.body.contains("<sl-button>Click</sl-button>"));
}
#[test]
fn empty_input() {
let out = render("");
assert!(out.body.is_empty());
assert!(out.esm.is_empty());
assert!(out.front_matter.is_none());
}
#[test]
fn jsx_trimmed_consistently() {
let out = render("<Card>\nContent\n</Card>\n");
assert!(out.body.contains("<Card>\n"));
assert!(out.body.contains("</Card>\n"));
assert!(!out.body.contains("<Card>\n\n"));
}
#[test]
fn disallowed_html_off_by_default() {
let input = "<script>alert('hi')</script>\n";
let out = render(input);
assert!(out.body.contains("<script>"));
}
#[test]
fn custom_options() {
let input = "# Heading\n\n~~struck~~\n";
let opts = Options {
strikethrough: true,
allow_html: true,
disallowed_raw_html: false,
heading_ids: false,
..Options::default()
};
let out = render_with_options(input, &opts);
assert!(out.body.contains("<del>struck</del>"));
assert!(!out.body.contains("id="));
}
#[test]
fn to_component_full() {
let input = "\
import { Card } from './card'
export const meta = { title: 'Test' }
# Title
<Card>
Content
</Card>
";
let out = render(input);
let comp = out.to_component("About");
assert!(comp.starts_with("import { Card } from './card'\n"));
assert!(comp.contains("export const meta = { title: 'Test' }\n"));
assert!(comp.contains("export function About() {"));
assert!(!comp.contains("default"));
assert!(comp.contains("<>"));
assert!(comp.contains("</>"));
assert!(comp.contains(" <h1"));
assert!(comp.contains(" <Card>"));
}
#[test]
fn to_component_no_esm() {
let out = render("# Hello\n");
let comp = out.to_component("Page");
assert!(comp.starts_with("export function Page() {"));
}
#[test]
fn to_component_empty_body() {
let out = render("import A from 'a'\n");
let comp = out.to_component("Empty");
assert!(comp.contains("import A from 'a'"));
assert!(comp.contains("<>\n </>"));
}
}