1use lex_extension::wire::{Format, HostNodeKind};
43use lex_extension::{
44 schema::{BodyKind, Schema},
45 AnnotationBody, LabelCtx, NodeRef, RenderOut,
46};
47use lex_extension_host::Registry;
48
49use crate::ir::nodes::{Annotation, DocNode, Document, InlineContent, Verbatim};
50use crate::ir::to_wire::{ir_annotation_body, ir_params_to_json};
51
52pub struct RenderedNode {
56 pub label: String,
58 pub output: Option<String>,
64 pub diagnostic: Option<String>,
69}
70
71pub struct RenderPlan {
75 pub nodes: Vec<RenderedNode>,
76 pub doc_scope_count: usize,
88 pub root_diagnostics: Vec<String>,
91}
92
93pub fn dispatch_render(document: &Document, registry: &Registry, format_name: &str) -> RenderPlan {
102 let mut nodes = Vec::new();
103 if registry.namespace_count() == 0 {
104 return RenderPlan {
105 nodes,
106 doc_scope_count: 0,
107 root_diagnostics: Vec::new(),
108 };
109 }
110 let format = format_for_name(format_name);
111 let mut ctx = WalkCtx {
112 registry,
113 format: &format,
114 out: &mut nodes,
115 };
116 for ann in &document.document_annotations {
122 visit_annotation(ann, HostNodeKind::Document, &mut ctx);
123 }
124 let doc_scope_count = ctx.out.len();
125 for child in &document.children {
126 walk_doc_node(child, HostNodeKind::Session, &mut ctx);
127 }
128 let root_diagnostics = registry
129 .take_root_diagnostics()
130 .into_iter()
131 .map(|d| d.message)
132 .collect();
133 RenderPlan {
134 nodes,
135 doc_scope_count,
136 root_diagnostics,
137 }
138}
139
140fn format_for_name(name: &str) -> Format {
141 match name.to_ascii_lowercase().as_str() {
142 "html" => Format::Html,
143 "markdown" | "md" => Format::Markdown,
144 "latex" | "tex" => Format::Latex,
145 "pdf" => Format::Pdf,
146 other => Format::Custom(other.to_string()),
147 }
148}
149
150struct WalkCtx<'a> {
155 registry: &'a Registry,
156 format: &'a Format,
161 out: &'a mut Vec<RenderedNode>,
162}
163
164fn walk_doc_node(node: &DocNode, parent_kind: HostNodeKind, ctx: &mut WalkCtx<'_>) {
165 match node {
166 DocNode::Heading(h) => {
167 for child in &h.children {
168 walk_doc_node(child, HostNodeKind::Session, ctx);
169 }
170 }
171 DocNode::Paragraph(_) | DocNode::Inline(_) => {}
172 DocNode::List(l) => {
173 for item in &l.items {
174 for child in &item.children {
175 walk_doc_node(child, HostNodeKind::ListItem, ctx);
176 }
177 }
178 }
179 DocNode::ListItem(li) => {
180 for child in &li.children {
181 walk_doc_node(child, HostNodeKind::ListItem, ctx);
182 }
183 }
184 DocNode::Definition(d) => {
185 for child in &d.description {
186 walk_doc_node(child, HostNodeKind::Definition, ctx);
187 }
188 }
189 DocNode::Annotation(a) => {
190 visit_annotation(a, parent_kind, ctx);
191 }
192 DocNode::Verbatim(v) => {
193 visit_verbatim(v, ctx);
194 }
195 DocNode::Table(t) => {
196 for cell in t.header.iter().chain(t.rows.iter()).flat_map(|r| &r.cells) {
201 for child in &cell.content {
202 walk_doc_node(child, HostNodeKind::Table, ctx);
203 }
204 }
205 for child in &t.footnotes {
206 walk_doc_node(child, HostNodeKind::List, ctx);
207 }
208 }
209 DocNode::Image(_) | DocNode::Video(_) | DocNode::Audio(_) | DocNode::Document(_) => {}
210 }
211}
212
213fn visit_annotation(annotation: &Annotation, attached_to: HostNodeKind, ctx: &mut WalkCtx<'_>) {
214 let label = annotation.label.clone();
215 if let Some(schema) = ctx.registry.schema_for(&label) {
216 if schema_has_render(&schema, ctx.format.as_str()) {
217 let body = body_for_schema(&annotation.content, &schema);
218 let label_ctx = LabelCtx {
219 label: label.clone(),
220 params: ir_params_to_json(&annotation.parameters),
221 body,
222 node: NodeRef {
223 kind: attached_to.as_str().to_string(),
224 range: zero_range(),
225 origin: None,
226 },
227 };
228 ctx.out
229 .push(dispatch_one(&label, ctx.registry, &label_ctx, ctx.format));
230 }
231 }
232 for child in &annotation.content {
238 walk_doc_node(child, HostNodeKind::Annotation, ctx);
239 }
240}
241
242fn visit_verbatim(v: &Verbatim, ctx: &mut WalkCtx<'_>) {
243 let Some(label) = v.language.as_deref() else {
254 return;
255 };
256 if label.is_empty() {
257 return;
258 }
259 let Some(schema) = ctx.registry.schema_for(label) else {
260 return;
261 };
262 if !schema.verbatim_label || !schema_has_render(&schema, ctx.format.as_str()) {
263 return;
264 }
265 let params_object = serde_json::Value::Object(
266 v.parameters
267 .iter()
268 .map(|(k, val)| (k.clone(), serde_json::Value::String(val.clone())))
269 .collect(),
270 );
271 let label_ctx = LabelCtx {
272 label: label.to_string(),
273 params: params_object,
274 body: AnnotationBody::Text(v.content.clone()),
275 node: NodeRef {
276 kind: HostNodeKind::Verbatim.as_str().to_string(),
277 range: zero_range(),
278 origin: None,
279 },
280 };
281 ctx.out
282 .push(dispatch_one(label, ctx.registry, &label_ctx, ctx.format));
283}
284
285fn dispatch_one(label: &str, registry: &Registry, ctx: &LabelCtx, format: &Format) -> RenderedNode {
286 match registry.dispatch_render(ctx, format.clone()) {
287 Ok(Some(RenderOut::String { string })) => RenderedNode {
288 label: label.to_string(),
289 output: Some(string),
290 diagnostic: None,
291 },
292 Ok(Some(RenderOut::WireAst { .. })) => RenderedNode {
293 label: label.to_string(),
294 output: None,
295 diagnostic: Some(format!(
296 "handler returned WireAst output for label `{label}` but the requested format is string-shaped; falling back to default rendering"
297 )),
298 },
299 Ok(None) => RenderedNode {
300 label: label.to_string(),
301 output: None,
302 diagnostic: None,
303 },
304 Err(diag) => RenderedNode {
305 label: label.to_string(),
306 output: None,
307 diagnostic: Some(diag.message),
308 },
309 }
310}
311
312fn schema_has_render(schema: &Schema, format_name: &str) -> bool {
313 schema
314 .hooks
315 .render
316 .iter()
317 .any(|h| h.0.eq_ignore_ascii_case(format_name))
318}
319
320fn body_for_schema(content: &[DocNode], schema: &Schema) -> AnnotationBody {
328 if schema.body.kind == BodyKind::Text {
329 let flat = flatten_text_body(content);
330 if flat.is_empty() {
331 AnnotationBody::None
332 } else {
333 AnnotationBody::Text(flat)
334 }
335 } else {
336 ir_annotation_body(content)
337 }
338}
339
340fn flatten_text_body(content: &[DocNode]) -> String {
354 fn flatten_inlines(inlines: &[InlineContent], buf: &mut String) {
355 for inline in inlines {
356 match inline {
357 InlineContent::Text(t)
358 | InlineContent::Code(t)
359 | InlineContent::Math(t)
360 | InlineContent::Reference { raw: t, .. } => buf.push_str(t),
361 InlineContent::Link { text, .. } => buf.push_str(text),
362 InlineContent::Bold(children) | InlineContent::Italic(children) => {
363 flatten_inlines(children, buf);
364 }
365 InlineContent::Image(_) => {}
366 }
367 }
368 }
369
370 let mut buf = String::new();
371 let mut first = true;
372 for node in content {
373 match node {
374 DocNode::Paragraph(p) => {
375 if !first {
376 buf.push(' ');
377 }
378 first = false;
379 flatten_inlines(&p.content, &mut buf);
380 }
381 DocNode::Inline(i) => {
382 flatten_inlines(std::slice::from_ref(i), &mut buf);
383 }
384 _ => {}
385 }
386 }
387 buf
388}
389
390fn zero_range() -> lex_extension::wire::Range {
391 use lex_extension::wire::{Position, Range};
392 Range::new(Position::new(0, 0), Position::new(0, 0))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use lex_core::lex::loader::DocumentLoader;
399 use lex_extension::schema::{
400 BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, RenderHook, Schema,
401 };
402 use lex_extension::wire::WireNode;
403 use lex_extension::{HandlerError, LexHandler};
404 use std::collections::BTreeMap;
405
406 fn parse(src: &str) -> Document {
407 let ast = DocumentLoader::from_string(src).parse().expect("parse");
408 crate::to_ir(&ast)
409 }
410
411 fn schema(label: &str, formats: &[&str]) -> Schema {
412 Schema {
413 schema_version: 1,
414 label: label.into(),
415 description: None,
416 params: BTreeMap::new(),
417 attaches_to: vec![
418 "annotation".into(),
419 "document".into(),
420 "session".into(),
421 "paragraph".into(),
422 ],
423 body: BodyShape {
424 kind: BodyKind::None,
425 presence: BodyPresence::Optional,
426 description: None,
427 },
428 verbatim_label: false,
429 capabilities: Capabilities::default(),
430 hooks: HookSet {
431 render: formats.iter().map(|s| RenderHook::new(*s)).collect(),
432 ..HookSet::default()
433 },
434 handler: None,
435 diagnostics: Vec::new(),
436 }
437 }
438
439 struct EchoRender;
440 impl LexHandler for EchoRender {
441 fn on_render(
442 &self,
443 ctx: &LabelCtx,
444 _fmt: Format,
445 ) -> Result<Option<RenderOut>, HandlerError> {
446 Ok(Some(RenderOut::String {
447 string: format!("<RENDERED label=\"{}\"/>", ctx.label),
448 }))
449 }
450 }
451
452 #[test]
453 fn empty_registry_yields_empty_plan() {
454 let doc = parse(":: acme.task ::\n");
455 let registry = Registry::new();
456 let plan = dispatch_render(&doc, ®istry, "html");
457 assert!(plan.nodes.is_empty());
458 }
459
460 #[test]
461 fn registered_label_with_html_render_hook_dispatches() {
462 let doc = parse(":: acme.task ::\n");
463 let registry = Registry::new();
464 registry
465 .register_namespace(
466 "acme",
467 vec![schema("acme.task", &["html"])],
468 Box::new(EchoRender),
469 )
470 .unwrap();
471 let plan = dispatch_render(&doc, ®istry, "html");
472 assert_eq!(plan.nodes.len(), 1);
473 assert_eq!(plan.nodes[0].label, "acme.task");
474 assert_eq!(
475 plan.nodes[0].output.as_deref(),
476 Some(r#"<RENDERED label="acme.task"/>"#)
477 );
478 }
479
480 #[test]
481 fn label_without_html_in_render_hooks_is_skipped() {
482 let doc = parse(":: acme.task ::\n");
483 let registry = Registry::new();
484 registry
485 .register_namespace(
486 "acme",
487 vec![schema("acme.task", &["markdown"])],
488 Box::new(EchoRender),
489 )
490 .unwrap();
491 let plan = dispatch_render(&doc, ®istry, "html");
492 assert!(plan.nodes.is_empty());
494 }
495
496 #[test]
503 fn alias_format_name_matches_canonical_schema_render_hook() {
504 let doc = parse(":: acme.task ::\n");
505 let registry = Registry::new();
506 registry
507 .register_namespace(
508 "acme",
509 vec![schema("acme.task", &["markdown"])],
510 Box::new(EchoRender),
511 )
512 .unwrap();
513 let plan = dispatch_render(&doc, ®istry, "md");
515 assert_eq!(plan.nodes.len(), 1);
516 assert_eq!(plan.nodes[0].label, "acme.task");
517 }
518
519 #[test]
520 fn handler_error_yields_diagnostic_and_no_html() {
521 struct Boom;
522 impl LexHandler for Boom {
523 fn on_render(
524 &self,
525 _: &LabelCtx,
526 _: Format,
527 ) -> Result<Option<RenderOut>, HandlerError> {
528 Err(HandlerError::internal("render failed"))
529 }
530 }
531 let doc = parse(":: acme.task ::\n");
532 let registry = Registry::new();
533 registry
534 .register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
535 .unwrap();
536 let plan = dispatch_render(&doc, ®istry, "html");
537 assert_eq!(plan.nodes.len(), 1);
538 assert!(plan.nodes[0].output.is_none());
539 let diag = plan.nodes[0].diagnostic.as_deref().expect("diagnostic");
540 assert!(diag.contains("render failed"));
541 }
542
543 #[test]
544 fn wire_ast_output_for_string_target_falls_back_with_diagnostic() {
545 struct WireOut;
546 impl LexHandler for WireOut {
547 fn on_render(
548 &self,
549 _: &LabelCtx,
550 _: Format,
551 ) -> Result<Option<RenderOut>, HandlerError> {
552 Ok(Some(RenderOut::WireAst {
553 ast: WireNode::Document {
554 range: lex_extension::wire::Range::new(
555 lex_extension::wire::Position(0, 0),
556 lex_extension::wire::Position(0, 0),
557 ),
558 origin: None,
559 children: vec![],
560 },
561 }))
562 }
563 }
564 let doc = parse(":: acme.task ::\n");
565 let registry = Registry::new();
566 registry
567 .register_namespace(
568 "acme",
569 vec![schema("acme.task", &["html"])],
570 Box::new(WireOut),
571 )
572 .unwrap();
573 let plan = dispatch_render(&doc, ®istry, "html");
574 assert_eq!(plan.nodes.len(), 1);
575 assert!(plan.nodes[0].output.is_none());
576 assert!(plan.nodes[0]
577 .diagnostic
578 .as_deref()
579 .is_some_and(|d| d.contains("WireAst")));
580 }
581
582 #[test]
586 fn end_to_end_html_pipeline_surfaces_diagnostics() {
587 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
588 struct Boom;
589 impl LexHandler for Boom {
590 fn on_render(
591 &self,
592 _: &LabelCtx,
593 _: Format,
594 ) -> Result<Option<RenderOut>, HandlerError> {
595 Err(HandlerError::internal("rendering failed"))
596 }
597 }
598 let ast = DocumentLoader::from_string(":: acme.task ::\n Body content.\n")
599 .parse()
600 .expect("parse");
601 let registry = Registry::new();
602 registry
603 .register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
604 .unwrap();
605 let outcome = serialize_to_html_with_registry(
606 &ast,
607 HtmlOptions::new(HtmlTheme::default()),
608 ®istry,
609 )
610 .expect("serialise");
611 assert!(
612 outcome
613 .diagnostics
614 .iter()
615 .any(|d| d.contains("rendering failed")),
616 "expected handler diagnostic in outcome, got: {:?}",
617 outcome.diagnostics
618 );
619 assert!(!outcome.html.is_empty());
620 }
621
622 #[test]
626 fn handler_sees_host_node_kind_in_label_ctx() {
627 use std::sync::Mutex;
628 struct CaptureKind {
629 seen: std::sync::Arc<Mutex<Vec<String>>>,
630 }
631 impl LexHandler for CaptureKind {
632 fn on_render(
633 &self,
634 ctx: &LabelCtx,
635 _: Format,
636 ) -> Result<Option<RenderOut>, HandlerError> {
637 self.seen.lock().unwrap().push(ctx.node.kind.clone());
638 Ok(None)
639 }
640 }
641 let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
642 let registry = Registry::new();
643 registry
644 .register_namespace(
645 "acme",
646 vec![schema("acme.task", &["html"])],
647 Box::new(CaptureKind { seen: seen.clone() }),
648 )
649 .unwrap();
650 let doc = parse(":: acme.task ::\n");
651 let _ = dispatch_render(&doc, ®istry, "html");
652 let kinds = seen.lock().unwrap().clone();
653 assert_eq!(
654 kinds.as_slice(),
655 &["document"],
656 "top-level annotation must surface as host kind \"document\"",
657 );
658 }
659
660 #[test]
661 fn end_to_end_html_pipeline_is_passthrough_when_no_hooks_match() {
662 use crate::formats::html::{
663 serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
664 };
665 let ast = DocumentLoader::from_string(":: acme.task ::\n Body.\n")
666 .parse()
667 .expect("parse");
668 let registry = Registry::new();
669 registry
671 .register_namespace(
672 "acme",
673 vec![schema("acme.task", &["markdown"])],
674 Box::new(EchoRender),
675 )
676 .unwrap();
677 let outcome = serialize_to_html_with_registry(
678 &ast,
679 HtmlOptions::new(HtmlTheme::default()),
680 ®istry,
681 )
682 .expect("serialise");
683 let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
684 assert_eq!(outcome.html, baseline);
685 assert!(outcome.diagnostics.is_empty());
686 }
687
688 fn registry_with_string_handler(label: &str, html_output: &'static str) -> Registry {
695 struct Fixed(&'static str);
696 impl LexHandler for Fixed {
697 fn on_render(
698 &self,
699 _: &LabelCtx,
700 _: Format,
701 ) -> Result<Option<RenderOut>, HandlerError> {
702 Ok(Some(RenderOut::String {
703 string: self.0.to_string(),
704 }))
705 }
706 }
707 let registry = Registry::new();
708 registry
709 .register_namespace(
710 label.split_once('.').map(|(ns, _)| ns).unwrap_or(label),
711 vec![schema(label, &["html"])],
712 Box::new(Fixed(html_output)),
713 )
714 .unwrap();
715 registry
716 }
717
718 const DOC_WITH_SCOPED_ANNOTATION: &str =
719 "1. Heading\n\n :: acme.task ::\n Body that should be replaced.\n";
720
721 #[test]
722 fn splice_replaces_default_annotation_rendering_with_handler_html() {
723 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
724 let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
725 .parse()
726 .expect("parse");
727 let registry = registry_with_string_handler(
728 "acme.task",
729 "<div class=\"acme-task\">handler-rendered</div>",
730 );
731 let outcome = serialize_to_html_with_registry(
732 &ast,
733 HtmlOptions::new(HtmlTheme::default()),
734 ®istry,
735 )
736 .expect("serialise");
737 assert!(
738 outcome
739 .html
740 .contains("<div class=\"acme-task\">handler-rendered</div>"),
741 "handler HTML should be spliced into the output. got:\n{}",
742 outcome.html
743 );
744 assert!(
745 !outcome.html.contains("<!-- lex:acme.task"),
746 "default start comment should be replaced by splice. got:\n{}",
747 outcome.html
748 );
749 assert!(
750 !outcome.html.contains("<!-- /lex:acme.task"),
751 "default end comment should be replaced by splice. got:\n{}",
752 outcome.html
753 );
754 }
755
756 #[test]
757 fn splice_consumes_annotation_body_so_default_content_does_not_render() {
758 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
759 let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
760 .parse()
761 .expect("parse");
762 let registry = registry_with_string_handler("acme.task", "<div>HANDLER</div>");
763 let outcome = serialize_to_html_with_registry(
764 &ast,
765 HtmlOptions::new(HtmlTheme::default()),
766 ®istry,
767 )
768 .expect("serialise");
769 assert!(outcome.html.contains("<div>HANDLER</div>"));
770 assert!(
771 !outcome.html.contains("Body that should be replaced."),
772 "annotation body must be suppressed inside the handler-owned region. got:\n{}",
773 outcome.html
774 );
775 }
776
777 #[test]
778 fn no_splice_when_handler_returns_none_falls_through_to_default() {
779 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
780 struct AlwaysNone;
781 impl LexHandler for AlwaysNone {
782 fn on_render(
783 &self,
784 _: &LabelCtx,
785 _: Format,
786 ) -> Result<Option<RenderOut>, HandlerError> {
787 Ok(None)
788 }
789 }
790 let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
791 .parse()
792 .expect("parse");
793 let registry = Registry::new();
794 registry
795 .register_namespace(
796 "acme",
797 vec![schema("acme.task", &["html"])],
798 Box::new(AlwaysNone),
799 )
800 .unwrap();
801 let outcome = serialize_to_html_with_registry(
802 &ast,
803 HtmlOptions::new(HtmlTheme::default()),
804 ®istry,
805 )
806 .expect("serialise");
807 assert!(
808 outcome.html.contains("<!-- lex:acme.task"),
809 "Ok(None) should fall through to default rendering. got:\n{}",
810 outcome.html
811 );
812 }
813
814 #[test]
815 fn no_splice_when_handler_returns_wire_ast_with_diagnostic() {
816 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
817 use lex_extension::wire::{Range as WireRange, WireNode};
818 use lex_extension::Position;
819 struct WireOnly;
820 impl LexHandler for WireOnly {
821 fn on_render(
822 &self,
823 _: &LabelCtx,
824 _: Format,
825 ) -> Result<Option<RenderOut>, HandlerError> {
826 Ok(Some(RenderOut::WireAst {
827 ast: WireNode::Paragraph {
828 range: WireRange::new(Position::new(0, 0), Position::new(0, 0)),
829 inlines: Vec::new(),
830 origin: None,
831 },
832 }))
833 }
834 }
835 let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
836 .parse()
837 .expect("parse");
838 let registry = Registry::new();
839 registry
840 .register_namespace(
841 "acme",
842 vec![schema("acme.task", &["html"])],
843 Box::new(WireOnly),
844 )
845 .unwrap();
846 let outcome = serialize_to_html_with_registry(
847 &ast,
848 HtmlOptions::new(HtmlTheme::default()),
849 ®istry,
850 )
851 .expect("serialise");
852 assert!(outcome.html.contains("<!-- lex:acme.task"));
853 assert!(
854 outcome
855 .diagnostics
856 .iter()
857 .any(|d| d.contains("WireAst") || d.contains("wire_ast")),
858 "expected WireAst-shape-mismatch diagnostic, got: {:?}",
859 outcome.diagnostics
860 );
861 }
862
863 #[test]
864 fn unregistered_namespace_renders_default_unchanged() {
865 use crate::formats::html::{
866 serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
867 };
868 let ast =
869 DocumentLoader::from_string("1. Heading\n\n :: unknown.label ::\n body.\n")
870 .parse()
871 .expect("parse");
872 let registry = Registry::new();
873 let outcome = serialize_to_html_with_registry(
874 &ast,
875 HtmlOptions::new(HtmlTheme::default()),
876 ®istry,
877 )
878 .expect("serialise");
879 let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
880 assert_eq!(outcome.html, baseline);
881 }
882
883 #[test]
893 fn doc_scope_annotation_does_not_misroute_body_splice() {
894 use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
895
896 struct ByLabel;
897 impl LexHandler for ByLabel {
898 fn on_render(
899 &self,
900 ctx: &LabelCtx,
901 _: Format,
902 ) -> Result<Option<RenderOut>, HandlerError> {
903 let marker = match ctx.label.as_str() {
904 "acme.docscope" => "<DOCSCOPE_HANDLER_OUTPUT/>",
905 "acme.body" => "<BODY_HANDLER_OUTPUT/>",
906 _ => return Ok(None),
907 };
908 Ok(Some(RenderOut::String {
909 string: marker.to_string(),
910 }))
911 }
912 }
913
914 let src = ":: acme.docscope ::\n\
915 \n\
916 1. Heading\n\
917 \n \
918 :: acme.body ::\n \
919 Body that should be replaced.\n";
920 let ast = DocumentLoader::from_string(src).parse().expect("parse");
921 let registry = Registry::new();
922 registry
923 .register_namespace(
924 "acme",
925 vec![
926 schema("acme.docscope", &["html"]),
927 schema("acme.body", &["html"]),
928 ],
929 Box::new(ByLabel),
930 )
931 .unwrap();
932 let ir_doc = crate::to_ir(&ast);
933 let plan = dispatch_render(&ir_doc, ®istry, "html");
934 assert_eq!(
935 plan.doc_scope_count, 1,
936 "dispatch_render should report one doc-scope plan entry"
937 );
938 assert_eq!(
939 plan.nodes.len(),
940 2,
941 "plan should have one doc-scope + one body entry"
942 );
943
944 let outcome = serialize_to_html_with_registry(
945 &ast,
946 HtmlOptions::new(HtmlTheme::default()),
947 ®istry,
948 )
949 .expect("serialise");
950
951 assert!(
952 outcome.html.contains("<BODY_HANDLER_OUTPUT/>"),
953 "body annotation must splice the body handler's output. got:\n{}",
954 outcome.html
955 );
956 assert!(
957 !outcome.html.contains("<DOCSCOPE_HANDLER_OUTPUT/>"),
958 "doc-scope handler output must not be spliced into the body. got:\n{}",
959 outcome.html
960 );
961 assert!(
962 !outcome.html.contains("Body that should be replaced."),
963 "body handler must own its body content. got:\n{}",
964 outcome.html
965 );
966 }
967
968 #[test]
975 fn dispatch_render_fires_from_markdown_path() {
976 let doc = parse(":: acme.task ::\n");
977 let registry = Registry::new();
978 registry
979 .register_namespace(
980 "acme",
981 vec![schema("acme.task", &["markdown"])],
982 Box::new(EchoRender),
983 )
984 .unwrap();
985 let plan = dispatch_render(&doc, ®istry, "markdown");
986 assert_eq!(plan.nodes.len(), 1, "markdown dispatch should fire");
987 assert_eq!(plan.nodes[0].label, "acme.task");
988 assert_eq!(
989 plan.nodes[0].output.as_deref(),
990 Some(r#"<RENDERED label="acme.task"/>"#),
991 "markdown handler output should surface in the plan"
992 );
993 }
994
995 #[test]
1000 fn annotation_body_surfaces_lex_children_to_handler() {
1001 use std::sync::Mutex;
1002 struct CaptureBody {
1003 seen: std::sync::Arc<Mutex<Option<AnnotationBody>>>,
1004 }
1005 impl LexHandler for CaptureBody {
1006 fn on_render(
1007 &self,
1008 ctx: &LabelCtx,
1009 _: Format,
1010 ) -> Result<Option<RenderOut>, HandlerError> {
1011 *self.seen.lock().unwrap() = Some(ctx.body.clone());
1012 Ok(None)
1013 }
1014 }
1015 let seen = std::sync::Arc::new(Mutex::new(None));
1016 let registry = Registry::new();
1017 registry
1018 .register_namespace(
1019 "acme",
1020 vec![schema("acme.task", &["html"])],
1021 Box::new(CaptureBody { seen: seen.clone() }),
1022 )
1023 .unwrap();
1024 let doc = parse(":: acme.task ::\n inside the body.\n");
1028 let _ = dispatch_render(&doc, ®istry, "html");
1029 let body = seen.lock().unwrap().clone().expect("handler ran");
1030 match body {
1031 AnnotationBody::Lex { children } => {
1032 assert!(
1033 !children.is_empty(),
1034 "lex body must carry annotation children"
1035 );
1036 }
1037 other => panic!("expected AnnotationBody::Lex, got {other:?}"),
1038 }
1039 }
1040
1041 #[test]
1047 fn verbatim_label_with_on_render_only_dispatches_via_language_field() {
1048 let registry = Registry::new();
1049 let mut s = schema("acme.snippet", &["html"]);
1054 s.verbatim_label = true;
1055 registry
1056 .register_namespace("acme", vec![s], Box::new(EchoRender))
1057 .unwrap();
1058 let doc = parse("Code:\n let x = 1;\n:: acme.snippet ::\n");
1061 let plan = dispatch_render(&doc, ®istry, "html");
1062 assert_eq!(
1063 plan.nodes.len(),
1064 1,
1065 "labelled verbatim with on_render hook should produce a plan entry"
1066 );
1067 assert_eq!(plan.nodes[0].label, "acme.snippet");
1068 assert!(plan.nodes[0]
1069 .output
1070 .as_deref()
1071 .is_some_and(|s| s.contains("acme.snippet")));
1072 }
1073
1074 #[test]
1080 fn table_header_cells_walked_before_body_cells() {
1081 use std::sync::Mutex;
1082 struct OrderedCapture {
1083 seen: std::sync::Arc<Mutex<Vec<String>>>,
1084 }
1085 impl LexHandler for OrderedCapture {
1086 fn on_render(
1087 &self,
1088 ctx: &LabelCtx,
1089 _: Format,
1090 ) -> Result<Option<RenderOut>, HandlerError> {
1091 self.seen.lock().unwrap().push(ctx.label.clone());
1092 Ok(None)
1093 }
1094 }
1095 let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
1096 let registry = Registry::new();
1097 registry
1098 .register_namespace(
1099 "acme",
1100 vec![
1101 schema("acme.in_header", &["html"]),
1102 schema("acme.in_body", &["html"]),
1103 ],
1104 Box::new(OrderedCapture { seen: seen.clone() }),
1105 )
1106 .unwrap();
1107 use crate::ir::nodes::{
1112 Annotation as IrAnn, DocNode as IrNode, Document as IrDoc, LabelForm,
1113 Paragraph as IrPara, Table as IrTable, TableCell as IrCell, TableCellAlignment,
1114 TableRow as IrRow,
1115 };
1116 fn cell_with_annotation(label: &str) -> IrCell {
1117 IrCell {
1118 content: vec![
1119 IrNode::Annotation(IrAnn {
1120 label: label.into(),
1121 parameters: Vec::new(),
1122 content: Vec::new(),
1123 form: LabelForm::Canonical,
1124 }),
1125 IrNode::Paragraph(IrPara {
1126 content: vec![crate::ir::nodes::InlineContent::Text("cell".into())],
1127 }),
1128 ],
1129 header: false,
1130 align: TableCellAlignment::None,
1131 colspan: 1,
1132 rowspan: 1,
1133 }
1134 }
1135 let doc = IrDoc {
1136 title: None,
1137 subtitle: None,
1138 children: vec![IrNode::Table(IrTable {
1139 rows: vec![IrRow {
1140 cells: vec![cell_with_annotation("acme.in_body")],
1141 }],
1142 header: vec![IrRow {
1143 cells: vec![cell_with_annotation("acme.in_header")],
1144 }],
1145 caption: None,
1146 footnotes: Vec::new(),
1147 fullwidth: false,
1148 })],
1149 document_annotations: Vec::new(),
1150 };
1151 let _ = dispatch_render(&doc, ®istry, "html");
1152 let kinds = seen.lock().unwrap().clone();
1153 assert_eq!(
1154 kinds.as_slice(),
1155 &["acme.in_header", "acme.in_body"],
1156 "header cells must be walked before body cells"
1157 );
1158 }
1159
1160 #[test]
1165 fn flatten_text_body_joins_paragraphs_with_space_not_newline() {
1166 use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
1167 let body = vec![
1168 IrNode::Paragraph(Paragraph {
1169 content: vec![InlineContent::Text("First line.".into())],
1170 }),
1171 IrNode::Paragraph(Paragraph {
1172 content: vec![InlineContent::Text("Second line.".into())],
1173 }),
1174 ];
1175 let flat = flatten_text_body(&body);
1176 assert_eq!(flat, "First line. Second line.");
1177 assert!(
1178 !flat.contains('\n'),
1179 "flattened scalar must not contain raw newlines"
1180 );
1181 }
1182
1183 #[test]
1187 fn flatten_text_body_processes_bare_inline_doc_nodes() {
1188 use crate::ir::nodes::{DocNode as IrNode, InlineContent};
1189 let body = vec![
1190 IrNode::Inline(InlineContent::Text("Hello, ".into())),
1191 IrNode::Inline(InlineContent::Text("world.".into())),
1192 ];
1193 assert_eq!(flatten_text_body(&body), "Hello, world.");
1194 }
1195
1196 #[test]
1202 fn flatten_text_body_recurses_through_bold_and_italic() {
1203 use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
1204 let body = vec![IrNode::Paragraph(Paragraph {
1205 content: vec![
1206 InlineContent::Italic(vec![InlineContent::Text("Important".into())]),
1207 InlineContent::Text(" ".into()),
1208 InlineContent::Bold(vec![
1209 InlineContent::Text("very ".into()),
1210 InlineContent::Italic(vec![InlineContent::Text("bold".into())]),
1211 ]),
1212 InlineContent::Text(" Title".into()),
1213 ],
1214 })];
1215 assert_eq!(flatten_text_body(&body), "Important very bold Title");
1216 }
1217}