on master help sign in
Add support for embedding external markdown files via @markdown directive
commit077590ab81a3f268290c1babdea978354dd69a1a authoriko <mail@iko.soy> dateSat 25 Apr 2026, 00:12 (5 months ago) parentc35fddb
2 files changed+131−10
crates/htmlang-core/src/parser.rs+45−10 tests/snapshots.rs+86
crates/htmlang-core/src/parser.rs+45−10
@@ -628,18 +628,53 @@ impl Parser {
628628 return Ok(None);
629629 }
630630
631− // --- @markdown block (convert markdown to HTML) ---
632− if content.trim() == "@markdown" {
633− let mut md_lines = Vec::new();
634− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
635− match &self.lines[self.pos].content {
636− LineContent::Normal(s) => md_lines.push(s.clone()),
637− LineContent::Raw(s) => md_lines.push(s.clone()),
631+ // --- @markdown block or file (convert markdown to HTML) ---
632+ if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
633+ let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
634+ if arg.is_empty() {
635+ // Inline markdown block: indented children
636+ let mut md_lines = Vec::new();
637+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
638+ match &self.lines[self.pos].content {
639+ LineContent::Normal(s) => md_lines.push(s.clone()),
640+ LineContent::Raw(s) => md_lines.push(s.clone()),
641+ }
642+ self.pos += 1;
638643 }
639− self.pos += 1;
644+ let html = markdown_to_html(&md_lines);
645+ return Ok(Some(vec![Node::Raw(html)]));
646+ } else {
647+ // External markdown file: @markdown file.md
648+ let filename = substitute_vars(arg, &ctx.variables);
649+ let resolved = match &ctx.base_path {
650+ Some(base) => base.join(&filename),
651+ None => PathBuf::from(&filename),
652+ };
653+ let md_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
654+ cached.clone()
655+ } else {
656+ match std::fs::read_to_string(&resolved) {
657+ Ok(text) => {
658+ ctx.file_cache.insert(resolved.clone(), text.clone());
659+ text
660+ }
661+ Err(e) => {
662+ ctx.diagnostics.push(Diagnostic {
663+ line: line_num,
664+ column: None,
665+ message: format!("cannot read markdown '{}': {}", filename, e),
666+ severity: Severity::Error,
667+ source_line: Some(content.clone()),
668+ });
669+ return Ok(None);
670+ }
671+ }
672+ };
673+ ctx.included_files.push(resolved);
674+ let md_lines: Vec<String> = md_text.lines().map(|l| l.to_string()).collect();
675+ let html = markdown_to_html(&md_lines);
676+ return Ok(Some(vec![Node::Raw(html)]));
640677 }
641− let html = markdown_to_html(&md_lines);
642− return Ok(Some(vec![Node::Raw(html)]));
643678 }
644679
645680 // --- @manifest (PWA web manifest) ---
tests/snapshots.rs+86
@@ -5095,3 +5095,89 @@ fn error_invalid_json_in_data_directive() {
50955095 result.diagnostics
50965096 );
50975097}
5098+
5099+// ---------------------------------------------------------------------------
5100+// @markdown file embedding tests
5101+// ---------------------------------------------------------------------------
5102+
5103+#[test]
5104+fn markdown_file_renders_content() {
5105+ let dir = std::env::temp_dir().join("htmlang_test_markdown_file");
5106+ let _ = std::fs::remove_dir_all(&dir);
5107+ std::fs::create_dir_all(&dir).unwrap();
5108+ let md_path = dir.join("article.md");
5109+ std::fs::write(&md_path, "# Hello\n\nThis is **bold** text.\n").unwrap();
5110+
5111+ let input = "@markdown article.md\n";
5112+ let result = htmlang::parser::parse_with_base(input, Some(&dir));
5113+ let html = htmlang::codegen::generate(&result.document);
5114+ let _ = std::fs::remove_dir_all(&dir);
5115+
5116+ assert!(
5117+ result
5118+ .diagnostics
5119+ .iter()
5120+ .all(|d| d.severity != htmlang::parser::Severity::Error),
5121+ "no errors expected, got: {:?}",
5122+ result.diagnostics
5123+ );
5124+ assert!(
5125+ html.contains("<h1>Hello</h1>"),
5126+ "should render heading from md file, got: {}",
5127+ html
5128+ );
5129+ assert!(
5130+ html.contains("<strong>bold</strong>"),
5131+ "should render bold from md file, got: {}",
5132+ html
5133+ );
5134+}
5135+
5136+#[test]
5137+fn markdown_file_with_variable_path() {
5138+ let dir = std::env::temp_dir().join("htmlang_test_markdown_var");
5139+ let _ = std::fs::remove_dir_all(&dir);
5140+ std::fs::create_dir_all(&dir).unwrap();
5141+ let md_path = dir.join("post.md");
5142+ std::fs::write(&md_path, "# Post Title\n").unwrap();
5143+
5144+ let input = "@let file post.md\n@markdown $file\n";
5145+ let result = htmlang::parser::parse_with_base(input, Some(&dir));
5146+ let html = htmlang::codegen::generate(&result.document);
5147+ let _ = std::fs::remove_dir_all(&dir);
5148+
5149+ assert!(
5150+ result
5151+ .diagnostics
5152+ .iter()
5153+ .all(|d| d.severity != htmlang::parser::Severity::Error),
5154+ "no errors expected, got: {:?}",
5155+ result.diagnostics
5156+ );
5157+ assert!(
5158+ html.contains("<h1>Post Title</h1>"),
5159+ "should resolve variable path for markdown file, got: {}",
5160+ html
5161+ );
5162+}
5163+
5164+#[test]
5165+fn markdown_file_missing_reports_error() {
5166+ let dir = std::env::temp_dir().join("htmlang_test_markdown_missing");
5167+ let _ = std::fs::remove_dir_all(&dir);
5168+ std::fs::create_dir_all(&dir).unwrap();
5169+
5170+ let input = "@markdown nonexistent.md\n";
5171+ let result = htmlang::parser::parse_with_base(input, Some(&dir));
5172+ let _ = std::fs::remove_dir_all(&dir);
5173+
5174+ let has_err = result.diagnostics.iter().any(|d| {
5175+ d.severity == htmlang::parser::Severity::Error
5176+ && d.message.contains("cannot read markdown")
5177+ });
5178+ assert!(
5179+ has_err,
5180+ "expected missing file error, got: {:?}",
5181+ result.diagnostics
5182+ );
5183+}