use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext}; use dashmap::DashMap; use markdown_weaver::{ Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, ParagraphContext, Tag, WeaverAttributes, }; use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; use n0_future::StreamExt; use std::ops::Range; use weaver_common::jacquard::{client::AgentSession, prelude::*}; /// Tracks the type of wrapper element emitted for WeaverBlock prefix #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WrapperElement { Aside, Div, } pub struct StaticPageWriter<'input, I: Iterator, Range)>, A: AgentSession, W: StrWrite> { context: NotebookProcessor<'input, I, StaticSiteContext>, writer: W, /// Source text for gap detection source: &'input str, /// Whether or not the last write wrote a newline. end_newline: bool, /// Whether if inside a metadata block (text should not be written) in_non_writing_block: bool, table_state: TableState, table_alignments: Vec, table_cell_index: usize, numbers: DashMap, usize>, code_buffer: Option<(Option, String)>, // (lang, content) /// Pending WeaverBlock attrs to apply to the next block element pending_block_attrs: Option>, /// Type of wrapper element currently open, and the block depth at which it was opened active_wrapper: Option<(WrapperElement, usize)>, /// Current block nesting depth (for wrapper close tracking) block_depth: usize, /// Buffer for WeaverBlock text content (to parse for attrs) weaver_block_buffer: String, /// Pending footnote reference waiting to see if definition follows immediately pending_footnote: Option<(CowStr<'static>, usize)>, /// Buffer for content between footnote ref and resolution pending_footnote_content: String, /// Whether current footnote definition is being rendered as a sidenote in_sidenote: bool, /// Whether we're deferring paragraph close for sidenote handling defer_paragraph_close: bool, /// Buffered paragraph opening tag (without closing `>`) for dir attribute emission pending_paragraph_open: Option, /// Byte offset where last sidenote ended (for gap detection) sidenote_end_offset: Option, } impl<'input, I: Iterator, Range)>, A: AgentSession, W: StrWrite> StaticPageWriter<'input, I, A, W> { pub fn new( context: NotebookProcessor<'input, I, StaticSiteContext>, writer: W, source: &'input str, ) -> Self { Self { context, writer, source, end_newline: true, in_non_writing_block: false, table_state: TableState::Head, table_alignments: vec![], table_cell_index: 0, numbers: DashMap::new(), code_buffer: None, pending_block_attrs: None, active_wrapper: None, block_depth: 0, weaver_block_buffer: String::new(), pending_footnote: None, pending_footnote_content: String::new(), in_sidenote: false, defer_paragraph_close: false, pending_paragraph_open: None, sidenote_end_offset: None, } } /// Parse WeaverBlock text content into attributes. /// Format: comma-separated, colon for key:value, otherwise class. /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")] fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { let mut classes = Vec::new(); let mut attrs = Vec::new(); for part in text.split(',') { let part = part.trim(); if part.is_empty() { continue; } if let Some((key, value)) = part.split_once(':') { let key = key.trim(); let value = value.trim(); if !key.is_empty() && !value.is_empty() { attrs.push(( CowStr::from(key.to_string()), CowStr::from(value.to_string()), )); } } else { // No colon - treat as class, strip leading dot if present let class = part.strip_prefix('.').unwrap_or(part); if !class.is_empty() { classes.push(CowStr::from(class.to_string())); } } } WeaverAttributes { classes, attrs } } /// Writes a new line. #[inline] fn write_newline(&mut self) -> Result<(), W::Error> { self.end_newline = true; self.writer.write_str("\n") } /// Writes a buffer, and tracks whether or not a newline was written. #[inline] fn write(&mut self, s: &str) -> Result<(), W::Error> { self.writer.write_str(s)?; if !s.is_empty() { self.end_newline = s.ends_with('\n'); } Ok(()) } /// Close deferred paragraph if we're in that state. /// Called when a non-paragraph block element starts. fn close_deferred_paragraph(&mut self) -> Result<(), W::Error> { // Also flush any pending paragraph open (shouldn't happen in normal flow, but be defensive) if let Some(opening) = self.pending_paragraph_open.take() { self.write(&opening)?; self.write(">")?; } if self.defer_paragraph_close { // Flush pending footnote as traditional before closing self.flush_pending_footnote()?; self.write("

\n")?; self.block_depth -= 1; self.close_wrapper()?; self.defer_paragraph_close = false; } Ok(()) } /// Flush any pending footnote reference as a traditional footnote, /// then write any buffered content that came after the reference. fn flush_pending_footnote(&mut self) -> Result<(), W::Error> { if let Some((name, number)) = self.pending_footnote.take() { // Emit traditional footnote reference self.write("
")?; write!(&mut self.writer, "{}", number)?; self.write("")?; // Write any buffered content if !self.pending_footnote_content.is_empty() { let content = std::mem::take(&mut self.pending_footnote_content); escape_html_body_text(&mut self.writer, &content)?; self.end_newline = content.ends_with('\n'); } } Ok(()) } /// Emit wrapper element start based on pending block attrs /// Returns true if a wrapper was emitted fn emit_wrapper_start(&mut self) -> Result { if let Some(attrs) = self.pending_block_attrs.take() { let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); if !self.end_newline { self.write("\n")?; } if is_aside { self.write(" element) let classes: Vec<_> = if is_aside { attrs .classes .iter() .filter(|c| c.as_ref() != "aside") .collect() } else { attrs.classes.iter().collect() }; if !classes.is_empty() { self.write(" class=\"")?; for (i, class) in classes.iter().enumerate() { if i > 0 { self.write(" ")?; } escape_html(&mut self.writer, class)?; } self.write("\"")?; } // Write other attrs for (attr, value) in &attrs.attrs { self.write(" ")?; escape_html(&mut self.writer, attr)?; self.write("=\"")?; escape_html(&mut self.writer, value)?; self.write("\"")?; } self.write(">\n")?; Ok(true) } else { Ok(false) } } /// Close active wrapper element if one is open and we're at the right depth fn close_wrapper(&mut self) -> Result<(), W::Error> { if let Some((wrapper, open_depth)) = self.active_wrapper.take() { if self.block_depth == open_depth { match wrapper { WrapperElement::Aside => self.write("\n")?, WrapperElement::Div => self.write("\n")?, } } else { // Not at the right depth yet, put it back self.active_wrapper = Some((wrapper, open_depth)); } } Ok(()) } fn end_tag(&mut self, tag: markdown_weaver::TagEnd, range: Range) -> Result<(), W::Error> { use markdown_weaver::TagEnd; match tag { TagEnd::HtmlBlock => {} TagEnd::Paragraph(ctx) => { if self.in_sidenote { // Inside sidenote span - don't emit paragraph tags } else if ctx == ParagraphContext::Interrupted && self.pending_footnote.is_some() { // Paragraph was interrupted AND we have a pending footnote, // defer the

close - the sidenote will be rendered inline self.defer_paragraph_close = true; // Don't decrement block_depth yet - we're continuing the virtual paragraph } else if self.defer_paragraph_close { // We were deferring but now closing for real self.write("

\n")?; self.block_depth -= 1; self.close_wrapper()?; self.defer_paragraph_close = false; } else { // Flush any pending paragraph open (for empty paragraphs) if let Some(opening) = self.pending_paragraph_open.take() { self.write(&opening)?; self.write(">")?; } self.write("

\n")?; self.block_depth -= 1; self.close_wrapper()?; } } TagEnd::Heading(level) => { self.write("\n")?; } TagEnd::Table => { self.write("\n")?; self.block_depth -= 1; self.close_wrapper()?; } TagEnd::TableHead => { self.write("\n")?; self.table_state = TableState::Body; } TagEnd::TableRow => { self.write("\n")?; } TagEnd::TableCell => { match self.table_state { TableState::Head => { self.write("")?; } TableState::Body => { self.write("")?; } } self.table_cell_index += 1; } TagEnd::BlockQuote(_) => { // Close any deferred paragraph before closing blockquote // (footnotes inside blockquotes can't be sidenotes since def is outside) self.close_deferred_paragraph()?; self.write("\n")?; self.block_depth -= 1; self.close_wrapper()?; } TagEnd::CodeBlock => { if let Some((lang, buffer)) = self.code_buffer.take() { if let Some(ref lang_str) = lang { // Use a temporary String buffer for syntect let mut temp_output = String::new(); match crate::code_pretty::highlight( &self.context.context.syntax_set, Some(lang_str), &buffer, &mut temp_output, ) { Ok(_) => { self.write(&temp_output)?; } Err(_) => { // Fallback to plain code block self.write("
")?;
                                escape_html_body_text(&mut self.writer, &buffer)?;
                                self.write("
\n")?; } } } else { self.write("
")?;
                        escape_html_body_text(&mut self.writer, &buffer)?;
                        self.write("
\n")?; } } else { self.write("\n")?; } self.block_depth -= 1; self.close_wrapper()?; } TagEnd::List(true) => { self.write("\n")?; self.block_depth -= 1; self.close_wrapper()?; } TagEnd::List(false) => { self.write("\n")?; self.block_depth -= 1; self.close_wrapper()?; } TagEnd::Item => { self.write("\n")?; } TagEnd::DefinitionList => { self.write("\n")?; self.block_depth -= 1; self.close_wrapper()?; } TagEnd::DefinitionListTitle => { self.write("\n")?; } TagEnd::DefinitionListDefinition => { self.write("\n")?; } TagEnd::Emphasis => { self.write("")?; } TagEnd::Superscript => { self.write("")?; } TagEnd::Subscript => { self.write("")?; } TagEnd::Strong => { self.write("")?; } TagEnd::Strikethrough => { self.write("")?; } TagEnd::Link => { self.write("")?; } TagEnd::Image => (), // shouldn't happen, handled in start TagEnd::Embed => (), // shouldn't happen, handled in start TagEnd::WeaverBlock(_) => { self.in_non_writing_block = false; eprintln!( "[TagEnd::WeaverBlock] buffer: {:?}", self.weaver_block_buffer ); // Parse the buffered text for attrs and store for next block if !self.weaver_block_buffer.is_empty() { let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); eprintln!("[TagEnd::WeaverBlock] parsed: {:?}", parsed); self.weaver_block_buffer.clear(); // Merge with any existing pending attrs or set new if let Some(ref mut existing) = self.pending_block_attrs { existing.classes.extend(parsed.classes); existing.attrs.extend(parsed.attrs); } else { self.pending_block_attrs = Some(parsed); } eprintln!( "[TagEnd::WeaverBlock] pending_block_attrs now: {:?}", self.pending_block_attrs ); } } TagEnd::FootnoteDefinition => { if self.in_sidenote { self.write("")?; self.in_sidenote = false; // Record where sidenote ended for gap detection self.sidenote_end_offset = Some(range.end); // Write any buffered content that came after the ref if !self.pending_footnote_content.is_empty() { let content = std::mem::take(&mut self.pending_footnote_content); escape_html_body_text(&mut self.writer, &content)?; self.end_newline = content.ends_with('\n'); } } else { self.write("\n")?; } } TagEnd::MetadataBlock(_) => { self.in_non_writing_block = false; } } Ok(()) } } impl< 'input, I: Iterator, Range)>, A: AgentSession + IdentityResolver + 'input, W: StrWrite, > StaticPageWriter<'input, I, A, W> { pub async fn run(mut self) -> Result<(), W::Error> { while let Some((event, range)) = self.context.next().await { self.process_event(event, range).await? } self.finalize() } /// Finalize output, closing any deferred state fn finalize(&mut self) -> Result<(), W::Error> { // Flush any pending footnote as traditional self.flush_pending_footnote()?; // Close deferred paragraph if any if self.defer_paragraph_close { self.write("

\n")?; self.block_depth -= 1; self.close_wrapper()?; self.defer_paragraph_close = false; } Ok(()) } async fn process_event(&mut self, event: Event<'input>, range: Range) -> Result<(), W::Error> { use markdown_weaver::Event::*; match event { Start(tag) => { println!("Start tag: {:?}", tag); self.start_tag(tag, range).await?; } End(tag) => { self.end_tag(tag, range)?; } Text(text) => { // If buffering code, append to buffer instead of writing if let Some((_, ref mut buffer)) = self.code_buffer { buffer.push_str(&text); } else if self.pending_footnote.is_some() { // Buffer text while waiting to see if footnote def follows self.pending_footnote_content.push_str(&text); } else if !self.in_non_writing_block { // Flush pending paragraph with dir attribute if needed if let Some(opening) = self.pending_paragraph_open.take() { if let Some(dir) = crate::utils::detect_text_direction(&text) { self.write(&opening)?; self.write(" dir=\"")?; self.write(dir)?; self.write("\">")?; } else { self.write(&opening)?; self.write(">")?; } } escape_html_body_text(&mut self.writer, &text)?; self.end_newline = text.ends_with('\n'); } } Code(text) => { self.write("")?; escape_html_body_text(&mut self.writer, &text)?; self.write("")?; } InlineMath(text) => { self.write(r#""#)?; escape_html(&mut self.writer, &text)?; self.write("")?; } DisplayMath(text) => { self.write(r#""#)?; escape_html(&mut self.writer, &text)?; self.write("")?; } Html(html) | InlineHtml(html) => { self.write(&html)?; } SoftBreak => { if self.pending_footnote.is_some() { self.pending_footnote_content.push('\n'); } else { self.write_newline()?; } } HardBreak => { if self.pending_footnote.is_some() { self.pending_footnote_content.push_str("
\n"); } else { self.write("
\n")?; } } Rule => { if self.end_newline { self.write("
\n")?; } else { self.write("\n
\n")?; } } FootnoteReference(name) => { // Flush any existing pending footnote as traditional self.flush_pending_footnote()?; // Get/create footnote number let len = self.numbers.len() + 1; let number = *self .numbers .entry(name.clone().into_static()) .or_insert(len); // Buffer this reference to see if definition follows immediately self.pending_footnote = Some((name.into_static(), number)); } TaskListMarker(true) => { self.write("\n")?; } TaskListMarker(false) => { self.write("\n")?; } WeaverBlock(text) => { // Buffer WeaverBlock content for parsing on End eprintln!("[WeaverBlock event] text: {:?}", text); self.weaver_block_buffer.push_str(&text); } } Ok(()) } // run raw text, consuming end tag async fn raw_text(&mut self) -> Result<(), W::Error> { use markdown_weaver::Event::*; let mut nest = 0; while let Some((event, _range)) = self.context.next().await { match event { Start(_) => nest += 1, End(_) => { if nest == 0 { break; } nest -= 1; } Html(_) => {} InlineHtml(text) | Code(text) | Text(text) => { // Don't use escape_html_body_text here. // The output of this function is used in the `alt` attribute. escape_html(&mut self.writer, &text)?; self.end_newline = text.ends_with('\n'); } InlineMath(text) => { self.write("$")?; escape_html(&mut self.writer, &text)?; self.write("$")?; } DisplayMath(text) => { self.write("$$")?; escape_html(&mut self.writer, &text)?; self.write("$$")?; } SoftBreak | HardBreak | Rule => { self.write(" ")?; } FootnoteReference(name) => { let len = self.numbers.len() + 1; let number = *self.numbers.entry(name.into_static()).or_insert(len); write!(&mut self.writer, "[{}]", number)?; } TaskListMarker(true) => self.write("[x]")?, TaskListMarker(false) => self.write("[ ]")?, WeaverBlock(_) => { println!("Weaver block internal"); } } } Ok(()) } /// Writes the start of an HTML tag. async fn start_tag(&mut self, tag: Tag<'input>, range: Range) -> Result<(), W::Error> { /// Minimum gap size that indicates a paragraph break (represents \n\n) const MIN_PARAGRAPH_GAP: usize = 2; match tag { Tag::HtmlBlock => Ok(()), Tag::Paragraph(_) => { if self.in_sidenote { // Inside sidenote span - don't emit paragraph tags Ok(()) } else if self.defer_paragraph_close { // Check gap size to decide whether to continue or start new paragraph if let Some(sidenote_end) = self.sidenote_end_offset.take() { let gap = range.start.saturating_sub(sidenote_end); if gap > MIN_PARAGRAPH_GAP { // Large gap - close deferred paragraph and start new one self.write("

\n")?; self.block_depth -= 1; self.close_wrapper()?; self.defer_paragraph_close = false; // Now start the new paragraph normally self.flush_pending_footnote()?; self.emit_wrapper_start()?; self.block_depth += 1; let opening = if self.end_newline { String::from(" { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; if self.end_newline { self.write("<")?; } else { self.write("\n<")?; } write!(&mut self.writer, "{}", level)?; if let Some(id) = id { self.write(" id=\"")?; escape_html(&mut self.writer, &id)?; self.write("\"")?; } let mut classes = classes.iter(); if let Some(class) = classes.next() { self.write(" class=\"")?; escape_html(&mut self.writer, class)?; for class in classes { self.write(" ")?; escape_html(&mut self.writer, class)?; } self.write("\"")?; } for (attr, value) in attrs { self.write(" ")?; escape_html(&mut self.writer, &attr)?; if let Some(val) = value { self.write("=\"")?; escape_html(&mut self.writer, &val)?; self.write("\"")?; } else { self.write("=\"\"")?; } } self.write(">") } Tag::Table(alignments) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; self.table_alignments = alignments; self.write("") } Tag::TableHead => { self.table_state = TableState::Head; self.table_cell_index = 0; self.write("") } Tag::TableRow => { self.table_cell_index = 0; self.write("") } Tag::TableCell => { match self.table_state { TableState::Head => { self.write(" { self.write(" self.write(" style=\"text-align: left\">"), Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), _ => self.write(">"), } } Tag::BlockQuote(kind) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; let class_str = match kind { None => "", Some(kind) => match kind { BlockQuoteKind::Note => " class=\"markdown-alert-note\"", BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"", BlockQuoteKind::Important => " class=\"markdown-alert-important\"", BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"", BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"", }, }; if self.end_newline { self.write(&format!("\n", class_str)) } else { self.write(&format!("\n\n", class_str)) } } Tag::CodeBlock(info) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; if !self.end_newline { self.write_newline()?; } match info { CodeBlockKind::Fenced(info) => { let lang = info.split(' ').next().unwrap(); let lang_opt = if lang.is_empty() { None } else { Some(lang.to_string()) }; // Start buffering self.code_buffer = Some((lang_opt, String::new())); Ok(()) } CodeBlockKind::Indented => { // Start buffering with no language self.code_buffer = Some((None, String::new())); Ok(()) } } } Tag::List(Some(1)) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; if self.end_newline { self.write("
    \n") } else { self.write("\n
      \n") } } Tag::List(Some(start)) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; if self.end_newline { self.write("
        \n") } Tag::List(None) => { self.close_deferred_paragraph()?; self.emit_wrapper_start()?; self.block_depth += 1; if self.end_newline { self.write("