atproto blogging
1use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext};
2use dashmap::DashMap;
3use markdown_weaver::{
4 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType,
5 ParagraphContext, Tag, WeaverAttributes,
6};
7use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
8use n0_future::StreamExt;
9use std::ops::Range;
10use weaver_common::jacquard::{client::AgentSession, prelude::*};
11
12/// Tracks the type of wrapper element emitted for WeaverBlock prefix
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum WrapperElement {
15 Aside,
16 Div,
17}
18
19pub struct StaticPageWriter<'input, I: Iterator<Item = (Event<'input>, Range<usize>)>, A: AgentSession, W: StrWrite>
20{
21 context: NotebookProcessor<'input, I, StaticSiteContext<A>>,
22 writer: W,
23 /// Source text for gap detection
24 source: &'input str,
25 /// Whether or not the last write wrote a newline.
26 end_newline: bool,
27
28 /// Whether if inside a metadata block (text should not be written)
29 in_non_writing_block: bool,
30
31 table_state: TableState,
32 table_alignments: Vec<Alignment>,
33 table_cell_index: usize,
34 numbers: DashMap<CowStr<'input>, usize>,
35
36 code_buffer: Option<(Option<String>, String)>, // (lang, content)
37
38 /// Pending WeaverBlock attrs to apply to the next block element
39 pending_block_attrs: Option<WeaverAttributes<'static>>,
40 /// Type of wrapper element currently open, and the block depth at which it was opened
41 active_wrapper: Option<(WrapperElement, usize)>,
42 /// Current block nesting depth (for wrapper close tracking)
43 block_depth: usize,
44 /// Buffer for WeaverBlock text content (to parse for attrs)
45 weaver_block_buffer: String,
46 /// Pending footnote reference waiting to see if definition follows immediately
47 pending_footnote: Option<(CowStr<'static>, usize)>,
48 /// Buffer for content between footnote ref and resolution
49 pending_footnote_content: String,
50 /// Whether current footnote definition is being rendered as a sidenote
51 in_sidenote: bool,
52 /// Whether we're deferring paragraph close for sidenote handling
53 defer_paragraph_close: bool,
54 /// Buffered paragraph opening tag (without closing `>`) for dir attribute emission
55 pending_paragraph_open: Option<String>,
56 /// Byte offset where last sidenote ended (for gap detection)
57 sidenote_end_offset: Option<usize>,
58}
59
60impl<'input, I: Iterator<Item = (Event<'input>, Range<usize>)>, A: AgentSession, W: StrWrite>
61 StaticPageWriter<'input, I, A, W>
62{
63 pub fn new(
64 context: NotebookProcessor<'input, I, StaticSiteContext<A>>,
65 writer: W,
66 source: &'input str,
67 ) -> Self {
68 Self {
69 context,
70 writer,
71 source,
72 end_newline: true,
73 in_non_writing_block: false,
74 table_state: TableState::Head,
75 table_alignments: vec![],
76 table_cell_index: 0,
77 numbers: DashMap::new(),
78 code_buffer: None,
79 pending_block_attrs: None,
80 active_wrapper: None,
81 block_depth: 0,
82 weaver_block_buffer: String::new(),
83 pending_footnote: None,
84 pending_footnote_content: String::new(),
85 in_sidenote: false,
86 defer_paragraph_close: false,
87 pending_paragraph_open: None,
88 sidenote_end_offset: None,
89 }
90 }
91
92 /// Parse WeaverBlock text content into attributes.
93 /// Format: comma-separated, colon for key:value, otherwise class.
94 /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")]
95 fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> {
96 let mut classes = Vec::new();
97 let mut attrs = Vec::new();
98
99 for part in text.split(',') {
100 let part = part.trim();
101 if part.is_empty() {
102 continue;
103 }
104
105 if let Some((key, value)) = part.split_once(':') {
106 let key = key.trim();
107 let value = value.trim();
108 if !key.is_empty() && !value.is_empty() {
109 attrs.push((
110 CowStr::from(key.to_string()),
111 CowStr::from(value.to_string()),
112 ));
113 }
114 } else {
115 // No colon - treat as class, strip leading dot if present
116 let class = part.strip_prefix('.').unwrap_or(part);
117 if !class.is_empty() {
118 classes.push(CowStr::from(class.to_string()));
119 }
120 }
121 }
122
123 WeaverAttributes { classes, attrs }
124 }
125
126 /// Writes a new line.
127 #[inline]
128 fn write_newline(&mut self) -> Result<(), W::Error> {
129 self.end_newline = true;
130 self.writer.write_str("\n")
131 }
132
133 /// Writes a buffer, and tracks whether or not a newline was written.
134 #[inline]
135 fn write(&mut self, s: &str) -> Result<(), W::Error> {
136 self.writer.write_str(s)?;
137
138 if !s.is_empty() {
139 self.end_newline = s.ends_with('\n');
140 }
141 Ok(())
142 }
143
144 /// Close deferred paragraph if we're in that state.
145 /// Called when a non-paragraph block element starts.
146 fn close_deferred_paragraph(&mut self) -> Result<(), W::Error> {
147 // Also flush any pending paragraph open (shouldn't happen in normal flow, but be defensive)
148 if let Some(opening) = self.pending_paragraph_open.take() {
149 self.write(&opening)?;
150 self.write(">")?;
151 }
152 if self.defer_paragraph_close {
153 // Flush pending footnote as traditional before closing
154 self.flush_pending_footnote()?;
155 self.write("</p>\n")?;
156 self.block_depth -= 1;
157 self.close_wrapper()?;
158 self.defer_paragraph_close = false;
159 }
160 Ok(())
161 }
162
163 /// Flush any pending footnote reference as a traditional footnote,
164 /// then write any buffered content that came after the reference.
165 fn flush_pending_footnote(&mut self) -> Result<(), W::Error> {
166 if let Some((name, number)) = self.pending_footnote.take() {
167 // Emit traditional footnote reference
168 self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
169 escape_html(&mut self.writer, &name)?;
170 self.write("\">")?;
171 write!(&mut self.writer, "{}", number)?;
172 self.write("</a></sup>")?;
173 // Write any buffered content
174 if !self.pending_footnote_content.is_empty() {
175 let content = std::mem::take(&mut self.pending_footnote_content);
176 escape_html_body_text(&mut self.writer, &content)?;
177 self.end_newline = content.ends_with('\n');
178 }
179 }
180 Ok(())
181 }
182
183 /// Emit wrapper element start based on pending block attrs
184 /// Returns true if a wrapper was emitted
185 fn emit_wrapper_start(&mut self) -> Result<bool, W::Error> {
186 if let Some(attrs) = self.pending_block_attrs.take() {
187 let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside");
188
189 if !self.end_newline {
190 self.write("\n")?;
191 }
192
193 if is_aside {
194 self.write("<aside")?;
195 self.active_wrapper = Some((WrapperElement::Aside, self.block_depth));
196 } else {
197 self.write("<div")?;
198 self.active_wrapper = Some((WrapperElement::Div, self.block_depth));
199 }
200
201 // Write classes (excluding "aside" if using <aside> element)
202 let classes: Vec<_> = if is_aside {
203 attrs
204 .classes
205 .iter()
206 .filter(|c| c.as_ref() != "aside")
207 .collect()
208 } else {
209 attrs.classes.iter().collect()
210 };
211
212 if !classes.is_empty() {
213 self.write(" class=\"")?;
214 for (i, class) in classes.iter().enumerate() {
215 if i > 0 {
216 self.write(" ")?;
217 }
218 escape_html(&mut self.writer, class)?;
219 }
220 self.write("\"")?;
221 }
222
223 // Write other attrs
224 for (attr, value) in &attrs.attrs {
225 self.write(" ")?;
226 escape_html(&mut self.writer, attr)?;
227 self.write("=\"")?;
228 escape_html(&mut self.writer, value)?;
229 self.write("\"")?;
230 }
231
232 self.write(">\n")?;
233 Ok(true)
234 } else {
235 Ok(false)
236 }
237 }
238
239 /// Close active wrapper element if one is open and we're at the right depth
240 fn close_wrapper(&mut self) -> Result<(), W::Error> {
241 if let Some((wrapper, open_depth)) = self.active_wrapper.take() {
242 if self.block_depth == open_depth {
243 match wrapper {
244 WrapperElement::Aside => self.write("</aside>\n")?,
245 WrapperElement::Div => self.write("</div>\n")?,
246 }
247 } else {
248 // Not at the right depth yet, put it back
249 self.active_wrapper = Some((wrapper, open_depth));
250 }
251 }
252 Ok(())
253 }
254
255 fn end_tag(&mut self, tag: markdown_weaver::TagEnd, range: Range<usize>) -> Result<(), W::Error> {
256 use markdown_weaver::TagEnd;
257 match tag {
258 TagEnd::HtmlBlock => {}
259 TagEnd::Paragraph(ctx) => {
260 if self.in_sidenote {
261 // Inside sidenote span - don't emit paragraph tags
262 } else if ctx == ParagraphContext::Interrupted && self.pending_footnote.is_some() {
263 // Paragraph was interrupted AND we have a pending footnote,
264 // defer the </p> close - the sidenote will be rendered inline
265 self.defer_paragraph_close = true;
266 // Don't decrement block_depth yet - we're continuing the virtual paragraph
267 } else if self.defer_paragraph_close {
268 // We were deferring but now closing for real
269 self.write("</p>\n")?;
270 self.block_depth -= 1;
271 self.close_wrapper()?;
272 self.defer_paragraph_close = false;
273 } else {
274 // Flush any pending paragraph open (for empty paragraphs)
275 if let Some(opening) = self.pending_paragraph_open.take() {
276 self.write(&opening)?;
277 self.write(">")?;
278 }
279 self.write("</p>\n")?;
280 self.block_depth -= 1;
281 self.close_wrapper()?;
282 }
283 }
284 TagEnd::Heading(level) => {
285 self.write("</")?;
286 write!(&mut self.writer, "{}", level)?;
287 self.block_depth -= 1;
288 // Don't close wrapper - headings typically go with following block
289 self.write(">\n")?;
290 }
291 TagEnd::Table => {
292 self.write("</tbody></table>\n")?;
293 self.block_depth -= 1;
294 self.close_wrapper()?;
295 }
296 TagEnd::TableHead => {
297 self.write("</tr></thead><tbody>\n")?;
298 self.table_state = TableState::Body;
299 }
300 TagEnd::TableRow => {
301 self.write("</tr>\n")?;
302 }
303 TagEnd::TableCell => {
304 match self.table_state {
305 TableState::Head => {
306 self.write("</th>")?;
307 }
308 TableState::Body => {
309 self.write("</td>")?;
310 }
311 }
312 self.table_cell_index += 1;
313 }
314 TagEnd::BlockQuote(_) => {
315 // Close any deferred paragraph before closing blockquote
316 // (footnotes inside blockquotes can't be sidenotes since def is outside)
317 self.close_deferred_paragraph()?;
318 self.write("</blockquote>\n")?;
319 self.block_depth -= 1;
320 self.close_wrapper()?;
321 }
322 TagEnd::CodeBlock => {
323 if let Some((lang, buffer)) = self.code_buffer.take() {
324 if let Some(ref lang_str) = lang {
325 // Use a temporary String buffer for syntect
326 let mut temp_output = String::new();
327 match crate::code_pretty::highlight(
328 &self.context.context.syntax_set,
329 Some(lang_str),
330 &buffer,
331 &mut temp_output,
332 ) {
333 Ok(_) => {
334 self.write(&temp_output)?;
335 }
336 Err(_) => {
337 // Fallback to plain code block
338 self.write("<pre><code class=\"language-")?;
339 escape_html(&mut self.writer, lang_str)?;
340 self.write("\">")?;
341 escape_html_body_text(&mut self.writer, &buffer)?;
342 self.write("</code></pre>\n")?;
343 }
344 }
345 } else {
346 self.write("<pre><code>")?;
347 escape_html_body_text(&mut self.writer, &buffer)?;
348 self.write("</code></pre>\n")?;
349 }
350 } else {
351 self.write("</code></pre>\n")?;
352 }
353 self.block_depth -= 1;
354 self.close_wrapper()?;
355 }
356 TagEnd::List(true) => {
357 self.write("</ol>\n")?;
358 self.block_depth -= 1;
359 self.close_wrapper()?;
360 }
361 TagEnd::List(false) => {
362 self.write("</ul>\n")?;
363 self.block_depth -= 1;
364 self.close_wrapper()?;
365 }
366 TagEnd::Item => {
367 self.write("</li>\n")?;
368 }
369 TagEnd::DefinitionList => {
370 self.write("</dl>\n")?;
371 self.block_depth -= 1;
372 self.close_wrapper()?;
373 }
374 TagEnd::DefinitionListTitle => {
375 self.write("</dt>\n")?;
376 }
377 TagEnd::DefinitionListDefinition => {
378 self.write("</dd>\n")?;
379 }
380 TagEnd::Emphasis => {
381 self.write("</em>")?;
382 }
383 TagEnd::Superscript => {
384 self.write("</sup>")?;
385 }
386 TagEnd::Subscript => {
387 self.write("</sub>")?;
388 }
389 TagEnd::Strong => {
390 self.write("</strong>")?;
391 }
392 TagEnd::Strikethrough => {
393 self.write("</del>")?;
394 }
395 TagEnd::Link => {
396 self.write("</a>")?;
397 }
398 TagEnd::Image => (), // shouldn't happen, handled in start
399 TagEnd::Embed => (), // shouldn't happen, handled in start
400 TagEnd::WeaverBlock(_) => {
401 self.in_non_writing_block = false;
402 eprintln!(
403 "[TagEnd::WeaverBlock] buffer: {:?}",
404 self.weaver_block_buffer
405 );
406 // Parse the buffered text for attrs and store for next block
407 if !self.weaver_block_buffer.is_empty() {
408 let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer);
409 eprintln!("[TagEnd::WeaverBlock] parsed: {:?}", parsed);
410 self.weaver_block_buffer.clear();
411 // Merge with any existing pending attrs or set new
412 if let Some(ref mut existing) = self.pending_block_attrs {
413 existing.classes.extend(parsed.classes);
414 existing.attrs.extend(parsed.attrs);
415 } else {
416 self.pending_block_attrs = Some(parsed);
417 }
418 eprintln!(
419 "[TagEnd::WeaverBlock] pending_block_attrs now: {:?}",
420 self.pending_block_attrs
421 );
422 }
423 }
424 TagEnd::FootnoteDefinition => {
425 if self.in_sidenote {
426 self.write("</span>")?;
427 self.in_sidenote = false;
428 // Record where sidenote ended for gap detection
429 self.sidenote_end_offset = Some(range.end);
430 // Write any buffered content that came after the ref
431 if !self.pending_footnote_content.is_empty() {
432 let content = std::mem::take(&mut self.pending_footnote_content);
433 escape_html_body_text(&mut self.writer, &content)?;
434 self.end_newline = content.ends_with('\n');
435 }
436 } else {
437 self.write("</div>\n")?;
438 }
439 }
440 TagEnd::MetadataBlock(_) => {
441 self.in_non_writing_block = false;
442 }
443 }
444 Ok(())
445 }
446}
447
448impl<
449 'input,
450 I: Iterator<Item = (Event<'input>, Range<usize>)>,
451 A: AgentSession + IdentityResolver + 'input,
452 W: StrWrite,
453> StaticPageWriter<'input, I, A, W>
454{
455 pub async fn run(mut self) -> Result<(), W::Error> {
456 while let Some((event, range)) = self.context.next().await {
457 self.process_event(event, range).await?
458 }
459 self.finalize()
460 }
461
462 /// Finalize output, closing any deferred state
463 fn finalize(&mut self) -> Result<(), W::Error> {
464 // Flush any pending footnote as traditional
465 self.flush_pending_footnote()?;
466 // Close deferred paragraph if any
467 if self.defer_paragraph_close {
468 self.write("</p>\n")?;
469 self.block_depth -= 1;
470 self.close_wrapper()?;
471 self.defer_paragraph_close = false;
472 }
473 Ok(())
474 }
475
476 async fn process_event(&mut self, event: Event<'input>, range: Range<usize>) -> Result<(), W::Error> {
477 use markdown_weaver::Event::*;
478 match event {
479 Start(tag) => {
480 println!("Start tag: {:?}", tag);
481 self.start_tag(tag, range).await?;
482 }
483 End(tag) => {
484 self.end_tag(tag, range)?;
485 }
486 Text(text) => {
487 // If buffering code, append to buffer instead of writing
488 if let Some((_, ref mut buffer)) = self.code_buffer {
489 buffer.push_str(&text);
490 } else if self.pending_footnote.is_some() {
491 // Buffer text while waiting to see if footnote def follows
492 self.pending_footnote_content.push_str(&text);
493 } else if !self.in_non_writing_block {
494 // Flush pending paragraph with dir attribute if needed
495 if let Some(opening) = self.pending_paragraph_open.take() {
496 if let Some(dir) = crate::utils::detect_text_direction(&text) {
497 self.write(&opening)?;
498 self.write(" dir=\"")?;
499 self.write(dir)?;
500 self.write("\">")?;
501 } else {
502 self.write(&opening)?;
503 self.write(">")?;
504 }
505 }
506 escape_html_body_text(&mut self.writer, &text)?;
507 self.end_newline = text.ends_with('\n');
508 }
509 }
510 Code(text) => {
511 self.write("<code>")?;
512 escape_html_body_text(&mut self.writer, &text)?;
513 self.write("</code>")?;
514 }
515 InlineMath(text) => {
516 self.write(r#"<span class="math math-inline">"#)?;
517 escape_html(&mut self.writer, &text)?;
518 self.write("</span>")?;
519 }
520 DisplayMath(text) => {
521 self.write(r#"<span class="math math-display">"#)?;
522 escape_html(&mut self.writer, &text)?;
523 self.write("</span>")?;
524 }
525 Html(html) | InlineHtml(html) => {
526 self.write(&html)?;
527 }
528 SoftBreak => {
529 if self.pending_footnote.is_some() {
530 self.pending_footnote_content.push('\n');
531 } else {
532 self.write_newline()?;
533 }
534 }
535 HardBreak => {
536 if self.pending_footnote.is_some() {
537 self.pending_footnote_content.push_str("<br />\n");
538 } else {
539 self.write("<br />\n")?;
540 }
541 }
542 Rule => {
543 if self.end_newline {
544 self.write("<hr />\n")?;
545 } else {
546 self.write("\n<hr />\n")?;
547 }
548 }
549 FootnoteReference(name) => {
550 // Flush any existing pending footnote as traditional
551 self.flush_pending_footnote()?;
552 // Get/create footnote number
553 let len = self.numbers.len() + 1;
554 let number = *self
555 .numbers
556 .entry(name.clone().into_static())
557 .or_insert(len);
558 // Buffer this reference to see if definition follows immediately
559 self.pending_footnote = Some((name.into_static(), number));
560 }
561 TaskListMarker(true) => {
562 self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\" aria-label=\"Completed task\"/>\n")?;
563 }
564 TaskListMarker(false) => {
565 self.write("<input disabled=\"\" type=\"checkbox\" aria-label=\"Incomplete task\"/>\n")?;
566 }
567 WeaverBlock(text) => {
568 // Buffer WeaverBlock content for parsing on End
569 eprintln!("[WeaverBlock event] text: {:?}", text);
570 self.weaver_block_buffer.push_str(&text);
571 }
572 }
573 Ok(())
574 }
575
576 // run raw text, consuming end tag
577 async fn raw_text(&mut self) -> Result<(), W::Error> {
578 use markdown_weaver::Event::*;
579 let mut nest = 0;
580 while let Some((event, _range)) = self.context.next().await {
581 match event {
582 Start(_) => nest += 1,
583 End(_) => {
584 if nest == 0 {
585 break;
586 }
587 nest -= 1;
588 }
589 Html(_) => {}
590 InlineHtml(text) | Code(text) | Text(text) => {
591 // Don't use escape_html_body_text here.
592 // The output of this function is used in the `alt` attribute.
593 escape_html(&mut self.writer, &text)?;
594 self.end_newline = text.ends_with('\n');
595 }
596 InlineMath(text) => {
597 self.write("$")?;
598 escape_html(&mut self.writer, &text)?;
599 self.write("$")?;
600 }
601 DisplayMath(text) => {
602 self.write("$$")?;
603 escape_html(&mut self.writer, &text)?;
604 self.write("$$")?;
605 }
606 SoftBreak | HardBreak | Rule => {
607 self.write(" ")?;
608 }
609 FootnoteReference(name) => {
610 let len = self.numbers.len() + 1;
611 let number = *self.numbers.entry(name.into_static()).or_insert(len);
612 write!(&mut self.writer, "[{}]", number)?;
613 }
614 TaskListMarker(true) => self.write("[x]")?,
615 TaskListMarker(false) => self.write("[ ]")?,
616 WeaverBlock(_) => {
617 println!("Weaver block internal");
618 }
619 }
620 }
621 Ok(())
622 }
623
624 /// Writes the start of an HTML tag.
625 async fn start_tag(&mut self, tag: Tag<'input>, range: Range<usize>) -> Result<(), W::Error> {
626 /// Minimum gap size that indicates a paragraph break (represents \n\n)
627 const MIN_PARAGRAPH_GAP: usize = 2;
628
629 match tag {
630 Tag::HtmlBlock => Ok(()),
631 Tag::Paragraph(_) => {
632 if self.in_sidenote {
633 // Inside sidenote span - don't emit paragraph tags
634 Ok(())
635 } else if self.defer_paragraph_close {
636 // Check gap size to decide whether to continue or start new paragraph
637 if let Some(sidenote_end) = self.sidenote_end_offset.take() {
638 let gap = range.start.saturating_sub(sidenote_end);
639 if gap > MIN_PARAGRAPH_GAP {
640 // Large gap - close deferred paragraph and start new one
641 self.write("</p>\n")?;
642 self.block_depth -= 1;
643 self.close_wrapper()?;
644 self.defer_paragraph_close = false;
645 // Now start the new paragraph normally
646 self.flush_pending_footnote()?;
647 self.emit_wrapper_start()?;
648 self.block_depth += 1;
649 let opening = if self.end_newline {
650 String::from("<p")
651 } else {
652 String::from("\n<p")
653 };
654 self.pending_paragraph_open = Some(opening);
655 } else {
656 // Small gap - continue same paragraph, just clear defer flag
657 self.defer_paragraph_close = false;
658 }
659 } else {
660 // No sidenote offset recorded, fall back to old behavior
661 self.defer_paragraph_close = false;
662 }
663 Ok(())
664 } else {
665 self.flush_pending_footnote()?;
666 self.emit_wrapper_start()?;
667 self.block_depth += 1;
668 // Buffer paragraph opening for dir attribute detection
669 let opening = if self.end_newline {
670 String::from("<p")
671 } else {
672 String::from("\n<p")
673 };
674 self.pending_paragraph_open = Some(opening);
675 Ok(())
676 }
677 }
678 Tag::Heading {
679 level,
680 id,
681 classes,
682 attrs,
683 } => {
684 self.close_deferred_paragraph()?;
685 self.emit_wrapper_start()?;
686 self.block_depth += 1;
687 if self.end_newline {
688 self.write("<")?;
689 } else {
690 self.write("\n<")?;
691 }
692 write!(&mut self.writer, "{}", level)?;
693 if let Some(id) = id {
694 self.write(" id=\"")?;
695 escape_html(&mut self.writer, &id)?;
696 self.write("\"")?;
697 }
698 let mut classes = classes.iter();
699 if let Some(class) = classes.next() {
700 self.write(" class=\"")?;
701 escape_html(&mut self.writer, class)?;
702 for class in classes {
703 self.write(" ")?;
704 escape_html(&mut self.writer, class)?;
705 }
706 self.write("\"")?;
707 }
708 for (attr, value) in attrs {
709 self.write(" ")?;
710 escape_html(&mut self.writer, &attr)?;
711 if let Some(val) = value {
712 self.write("=\"")?;
713 escape_html(&mut self.writer, &val)?;
714 self.write("\"")?;
715 } else {
716 self.write("=\"\"")?;
717 }
718 }
719 self.write(">")
720 }
721 Tag::Table(alignments) => {
722 self.close_deferred_paragraph()?;
723 self.emit_wrapper_start()?;
724 self.block_depth += 1;
725 self.table_alignments = alignments;
726 self.write("<table>")
727 }
728 Tag::TableHead => {
729 self.table_state = TableState::Head;
730 self.table_cell_index = 0;
731 self.write("<thead><tr>")
732 }
733 Tag::TableRow => {
734 self.table_cell_index = 0;
735 self.write("<tr>")
736 }
737 Tag::TableCell => {
738 match self.table_state {
739 TableState::Head => {
740 self.write("<th")?;
741 }
742 TableState::Body => {
743 self.write("<td")?;
744 }
745 }
746 match self.table_alignments.get(self.table_cell_index) {
747 Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
748 Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
749 Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
750 _ => self.write(">"),
751 }
752 }
753 Tag::BlockQuote(kind) => {
754 self.close_deferred_paragraph()?;
755 self.emit_wrapper_start()?;
756 self.block_depth += 1;
757 let class_str = match kind {
758 None => "",
759 Some(kind) => match kind {
760 BlockQuoteKind::Note => " class=\"markdown-alert-note\"",
761 BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"",
762 BlockQuoteKind::Important => " class=\"markdown-alert-important\"",
763 BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"",
764 BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"",
765 },
766 };
767 if self.end_newline {
768 self.write(&format!("<blockquote{}>\n", class_str))
769 } else {
770 self.write(&format!("\n<blockquote{}>\n", class_str))
771 }
772 }
773 Tag::CodeBlock(info) => {
774 self.close_deferred_paragraph()?;
775 self.emit_wrapper_start()?;
776 self.block_depth += 1;
777 if !self.end_newline {
778 self.write_newline()?;
779 }
780 match info {
781 CodeBlockKind::Fenced(info) => {
782 let lang = info.split(' ').next().unwrap();
783 let lang_opt = if lang.is_empty() {
784 None
785 } else {
786 Some(lang.to_string())
787 };
788 // Start buffering
789 self.code_buffer = Some((lang_opt, String::new()));
790 Ok(())
791 }
792 CodeBlockKind::Indented => {
793 // Start buffering with no language
794 self.code_buffer = Some((None, String::new()));
795 Ok(())
796 }
797 }
798 }
799 Tag::List(Some(1)) => {
800 self.close_deferred_paragraph()?;
801 self.emit_wrapper_start()?;
802 self.block_depth += 1;
803 if self.end_newline {
804 self.write("<ol>\n")
805 } else {
806 self.write("\n<ol>\n")
807 }
808 }
809 Tag::List(Some(start)) => {
810 self.close_deferred_paragraph()?;
811 self.emit_wrapper_start()?;
812 self.block_depth += 1;
813 if self.end_newline {
814 self.write("<ol start=\"")?;
815 } else {
816 self.write("\n<ol start=\"")?;
817 }
818 write!(&mut self.writer, "{}", start)?;
819 self.write("\">\n")
820 }
821 Tag::List(None) => {
822 self.close_deferred_paragraph()?;
823 self.emit_wrapper_start()?;
824 self.block_depth += 1;
825 if self.end_newline {
826 self.write("<ul>\n")
827 } else {
828 self.write("\n<ul>\n")
829 }
830 }
831 Tag::Item => {
832 if self.end_newline {
833 self.write("<li>")
834 } else {
835 self.write("\n<li>")
836 }
837 }
838 Tag::DefinitionList => {
839 self.close_deferred_paragraph()?;
840 self.emit_wrapper_start()?;
841 self.block_depth += 1;
842 if self.end_newline {
843 self.write("<dl>\n")
844 } else {
845 self.write("\n<dl>\n")
846 }
847 }
848 Tag::DefinitionListTitle => {
849 if self.end_newline {
850 self.write("<dt>")
851 } else {
852 self.write("\n<dt>")
853 }
854 }
855 Tag::DefinitionListDefinition => {
856 if self.end_newline {
857 self.write("<dd>")
858 } else {
859 self.write("\n<dd>")
860 }
861 }
862 Tag::Subscript => self.write("<sub>"),
863 Tag::Superscript => self.write("<sup>"),
864 Tag::Emphasis => self.write("<em>"),
865 Tag::Strong => self.write("<strong>"),
866 Tag::Strikethrough => self.write("<del>"),
867 Tag::Link {
868 link_type: LinkType::Email,
869 dest_url,
870 title,
871 id: _,
872 } => {
873 self.write("<a href=\"mailto:")?;
874 escape_href(&mut self.writer, &dest_url)?;
875 if !title.is_empty() {
876 self.write("\" title=\"")?;
877 escape_html(&mut self.writer, &title)?;
878 }
879 self.write("\">")
880 }
881 Tag::Link {
882 link_type: _,
883 dest_url,
884 title,
885 id: _,
886 } => {
887 self.write("<a href=\"")?;
888 escape_href(&mut self.writer, &dest_url)?;
889 if !title.is_empty() {
890 self.write("\" title=\"")?;
891 escape_html(&mut self.writer, &title)?;
892 }
893 self.write("\">")
894 }
895 Tag::Image {
896 link_type,
897 dest_url,
898 title,
899 id,
900 attrs,
901 } => {
902 //println!("Image tag {}", dest_url);
903 self.write_image(Tag::Image {
904 link_type,
905 dest_url,
906 title,
907 id,
908 attrs,
909 })
910 .await
911 }
912 Tag::Embed {
913 embed_type,
914 dest_url,
915 title,
916 id,
917 attrs,
918 } => {
919 //println!("Embed {:?}: {} - {}", embed_type, title, dest_url);
920 if let Some(attrs) = attrs {
921 if let Some((_, content)) = attrs
922 .attrs
923 .iter()
924 .find(|(attr, _)| attr.as_ref() == "content")
925 {
926 match embed_type {
927 EmbedType::Image => {
928 self.write_image(Tag::Image {
929 link_type: LinkType::Inline,
930 dest_url,
931 title,
932 id,
933 attrs: Some(attrs.clone()),
934 })
935 .await?
936 }
937 EmbedType::Comments => {
938 self.write("leaflet would go here\n")?;
939 }
940 EmbedType::Post => {
941 // Bluesky post embed, basically just render the raw html we got
942 self.write(content)?;
943 self.write_newline()?;
944 }
945 EmbedType::Markdown => {
946 // let context = self
947 // .context
948 // .context
949 // .clone_with_path(&Path::new(&dest_url.to_string()));
950 // let callback =
951 // if let Some(dir_contents) = context.dir_contents.clone() {
952 // Some(VaultBrokenLinkCallback {
953 // vault_contents: dir_contents,
954 // })
955 // } else {
956 // None
957 // };
958 // let parser = Parser::new_with_broken_link_callback(
959 // &content,
960 // context.md_options,
961 // callback,
962 // );
963 // let iterator = ContextIterator::default(parser);
964 // let mut stream = NotebookProcessor::new(context, iterator);
965 // while let Some(event) = stream.next().await {
966 // self.process_event(event).await?;
967 // }
968 //
969 self.write("markdown embed would go here\n")?;
970 }
971 EmbedType::Leaflet => {
972 self.write("leaflet would go here\n")?;
973 }
974 EmbedType::Other => {
975 self.write("other embed would go here\n")?;
976 }
977 }
978 }
979 } else {
980 self.write("<iframe src=\"")?;
981 escape_href(&mut self.writer, &dest_url)?;
982 self.write("\" title=\"")?;
983 escape_html(&mut self.writer, &title)?;
984 if !id.is_empty() {
985 self.write("\" id=\"")?;
986 escape_html(&mut self.writer, &id)?;
987 self.write("\"")?;
988 }
989 if let Some(attrs) = attrs {
990 self.write(" ")?;
991 if !attrs.classes.is_empty() {
992 self.write("class=\"")?;
993 for class in &attrs.classes {
994 escape_html(&mut self.writer, class)?;
995 self.write(" ")?;
996 }
997 self.write("\" ")?;
998 }
999 if !attrs.attrs.is_empty() {
1000 for (attr, value) in &attrs.attrs {
1001 escape_html(&mut self.writer, attr)?;
1002 self.write("=\"")?;
1003 escape_html(&mut self.writer, value)?;
1004 self.write("\" ")?;
1005 }
1006 }
1007 }
1008 self.write("/>")?;
1009 }
1010 Ok(())
1011 }
1012 Tag::WeaverBlock(_, attrs) => {
1013 self.in_non_writing_block = true;
1014 self.weaver_block_buffer.clear();
1015 // Store attrs from Start tag, will merge with parsed text on End
1016 if !attrs.classes.is_empty() || !attrs.attrs.is_empty() {
1017 self.pending_block_attrs = Some(attrs.into_static());
1018 }
1019 Ok(())
1020 }
1021 Tag::FootnoteDefinition(name) => {
1022 // Check if this matches a pending footnote reference (sidenote case)
1023 let is_sidenote = self
1024 .pending_footnote
1025 .as_ref()
1026 .map(|(n, _)| n.as_ref() == name.as_ref())
1027 .unwrap_or(false);
1028
1029 if is_sidenote {
1030 // Emit sidenote structure at reference position
1031 let (_, number) = self.pending_footnote.take().unwrap();
1032 let id = format!("sn-{}", number);
1033
1034 // Emit: <label><input/><span class="sidenote">
1035 self.write("<label for=\"")?;
1036 self.write(&id)?;
1037 self.write("\" class=\"sidenote-number\"></label>")?;
1038 self.write("<input type=\"checkbox\" id=\"")?;
1039 self.write(&id)?;
1040 self.write("\" class=\"margin-toggle\"/>")?;
1041 self.write("<span class=\"sidenote\">")?;
1042
1043 // Write any buffered content AFTER the sidenote span closes
1044 // (we'll do this in end_tag)
1045 self.in_sidenote = true;
1046 } else {
1047 // Traditional footnote - close any deferred paragraph (which also flushes pending ref)
1048 self.close_deferred_paragraph()?;
1049
1050 if self.end_newline {
1051 self.write("<div class=\"footnote-definition\" id=\"")?;
1052 } else {
1053 self.write("\n<div class=\"footnote-definition\" id=\"")?;
1054 }
1055 escape_html(&mut self.writer, &name)?;
1056 self.write("\"><sup class=\"footnote-definition-label\">")?;
1057 let len = self.numbers.len() + 1;
1058 let number = *self.numbers.entry(name.into_static()).or_insert(len);
1059 write!(&mut self.writer, "{}", number)?;
1060 self.write("</sup>")?;
1061 }
1062 Ok(())
1063 }
1064 Tag::MetadataBlock(_) => {
1065 self.in_non_writing_block = true;
1066 Ok(())
1067 }
1068 }
1069 }
1070
1071 async fn write_image(&mut self, tag: Tag<'input>) -> Result<(), W::Error> {
1072 if let Tag::Image {
1073 link_type: _,
1074 dest_url,
1075 title,
1076 id: _,
1077 attrs,
1078 } = tag
1079 {
1080 self.write("<img src=\"")?;
1081 escape_href(&mut self.writer, &dest_url)?;
1082 if let Some(attrs) = attrs {
1083 if !attrs.classes.is_empty() {
1084 self.write("\" class=\"")?;
1085 for class in &attrs.classes {
1086 escape_html(&mut self.writer, class)?;
1087 self.write(" ")?;
1088 }
1089 self.write("\" ")?;
1090 } else {
1091 self.write("\" ")?;
1092 }
1093 if !attrs.attrs.is_empty() {
1094 for (attr, value) in &attrs.attrs {
1095 escape_html(&mut self.writer, attr)?;
1096 self.write("=\"")?;
1097 escape_html(&mut self.writer, value)?;
1098 self.write("\" ")?;
1099 }
1100 }
1101 } else {
1102 self.write("\" ")?;
1103 }
1104 self.write("alt=\"")?;
1105 self.raw_text().await?;
1106 if !title.is_empty() {
1107 self.write("\" title=\"")?;
1108 escape_html(&mut self.writer, &title)?;
1109 }
1110 self.write("\" />")
1111 } else {
1112 self.write_newline()
1113 }
1114 }
1115}